commit e44a6d12cba3babd2188d8c6ff4caf4cbf3d5359 Author: gitea-actions Date: Sun Aug 23 09:29:45 2026 +0000 docs: publish site from 7660cf21 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..fd724939 --- /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..d95f4313 --- /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..0be42980 --- /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..5b9f6441 --- /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..f21e6506 --- /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..d447bf54 --- /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

Macros

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..f6605a3d --- /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..adff9c42 --- /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..b99ff11d --- /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..30ed552c --- /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..bc0f8d33 --- /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..8fc664e6 --- /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..df654519 --- /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..9fa4c954 --- /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..d5e79157 --- /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..f71fecfd --- /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..17b4f403 --- /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..91a32255 --- /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..9b0cceb1 --- /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..f8e970d1 --- /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..0685364e --- /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..9a88aab4 --- /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..b4cb905d --- /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..85a928d8 --- /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..bf0f5623 --- /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..93ae9f44 --- /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..3e361a78 --- /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..18d54e69 --- /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..52f8b52c --- /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..f5d98d4b --- /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..fea8c859 --- /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..0cf7f1ea --- /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..15eb1227 --- /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..83f24d9e --- /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..6fbf86e8 --- /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..9d87b844 --- /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..58cebd8d --- /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..f5b59e03 --- /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..142970f4 --- /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..3880db17 --- /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..a8e4ae5d --- /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..65f52f8c --- /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..ea9a99d4 --- /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..99147681 --- /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..762ae592 --- /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..202538c4 --- /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..e1976807 --- /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..bfb9f9e2 --- /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..c3e211d1 --- /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..ce88246d --- /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..e8705890 --- /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..ec428c7b --- /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..94d8eff8 --- /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..11bd2c27 --- /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..dd3e4945 --- /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..ff2d8a6f --- /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..1351ab19 --- /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..3ed53f99 --- /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..17b7a157 --- /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..12023725 --- /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..207d55ba --- /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..d9192473 --- /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..277fd329 --- /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..66fc5748 --- /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..a7d4140a --- /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..33f50cf3 --- /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..71fe2399 --- /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..e188d2d0 --- /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..a57b8346 --- /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..4b5b8460 --- /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..b0b5f4d2 --- /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..e612193d --- /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..731d7e3e --- /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..f7f8b986 --- /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..745e6f5f --- /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..3630b7f9 --- /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..84f3b4c2 --- /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..5f6d2ac0 --- /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..08096f01 --- /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..9108bae7 --- /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..c00126f6 --- /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..ee37e59a --- /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..cd2ab90c --- /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..386c2160 --- /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..4fa2e47f --- /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..c50bcf88 --- /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..3f4be37f --- /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..c986cf13 --- /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..e26b9bc3 --- /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..ebfc1df4 --- /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..458bbfb1 --- /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..89f004f0 --- /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..14caeccf --- /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..79cf73d0 --- /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..97662a6a --- /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..aad87f20 --- /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..fbb8fd25 --- /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..f28a283c --- /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..902998b4 --- /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..4984751e --- /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..144ccba3 --- /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..b5bb6131 --- /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..4f88f8a7 --- /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..f34af7c9 --- /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..815d23f6 --- /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..c458d2db --- /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..13d29b41 --- /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..89668f84 --- /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..4e2dd363 --- /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..20bd7968 --- /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..d156746f --- /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..890857cd --- /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..afec03cc --- /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..f43a96e5 --- /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..8be444fd --- /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..e530c7b3 --- /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..a4d82c7b --- /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..c8bbd3b3 --- /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..f417aac0 --- /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..6f1cefca --- /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..7c08b87e --- /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..8f33dfdb --- /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..8965e7f0 --- /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..f0add78b --- /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..8f5ed7fa --- /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..6713fa97 --- /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..7b431b89 --- /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..20f7f2cf --- /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..ae699842 --- /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..34e0feda --- /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..0036f04e --- /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..d92b8d6b --- /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..3edac738 --- /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..62a7aeff --- /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..0ceaa5d8 --- /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..b220ec80 --- /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..18997b7c --- /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..39cc412d --- /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..a2ecc1da --- /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..19984fd5 --- /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..719ad6b8 --- /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..84313f59 --- /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..8b03e08b --- /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..4a0c5551 --- /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..c234d7f2 --- /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..2d618830 --- /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..56a6e572 --- /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..2ec2b07a --- /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..114c2496 --- /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..264e4518 --- /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..1ffcb3b5 --- /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..60acc7e2 --- /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..ee217862 --- /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..7369a80d --- /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..d5289b0c --- /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..fd19a2f2 --- /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..039a1042 --- /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..e9cc1762 --- /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..973464c0 --- /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..a4b0f668 --- /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..517d3a34 --- /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..2cf5d6e5 --- /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..e5ac9055 --- /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..69c4a0d8 --- /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..d3d449e5 --- /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..2231b69b --- /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..17c30a3f --- /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..a8fbda3d --- /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..613475b0 --- /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..770dcda3 --- /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..fc78456a --- /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..20b04257 --- /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..10182315 --- /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..7064db96 --- /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..9ec28d5d --- /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..be3445db --- /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..920ae586 --- /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..5bcf2fcd --- /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..8c0ec7d6 --- /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..57950e88 --- /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..42cb9138 --- /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..718d4940 --- /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..092ee76b --- /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..8b2c2ac2 --- /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..b745a146 --- /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..d7b01b5d --- /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..40aba787 --- /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..998a59d3 --- /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..ede833d1 --- /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..aded1b10 --- /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..c8167298 --- /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..313dd01d --- /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 {
+        selection: StreamSelection,
+        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

§selection: StreamSelection

What to open, and how. TRACES: UR-079 | DR-225

+
§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..d9068b80 --- /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..05fdb1ea --- /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..3d015f66 --- /dev/null +++ b/api/jellytau_lib/commands/player/enum.StreamQualityResponse.html @@ -0,0 +1,68 @@ +StreamQualityResponse in jellytau_lib::commands::player - RustSkip to main content

StreamQualityResponse

Enum StreamQualityResponse 

Source
pub enum StreamQualityResponse {
+    Native {
+        selection: StreamSelection,
+        position: f64,
+    },
+    ReloadStream {
+        selection: StreamSelection,
+        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 to +do — but it still has to be told what was negotiated.

+

This carried only a position at first, which left the picker on Android +pinned to the rendition of the first stream: the UI derives the rung in +force from the selection it holds, nothing replaced that selection on the +native path, and a transcode always has a rendition — so the fallback +that would have used the requested value was never reached. The stream +changed and the menu did not.

+

TRACES: UR-074, UR-079 | DR-226, DR-227

+

Fields

§selection: StreamSelection

What the backend actually opened, so the UI reflects it rather than +assuming the request was honoured verbatim.

+
§position: f64

Position playback resumed at.

+
§

ReloadStream

HTML5 must reload its element with this selection.

+

Fields

§selection: StreamSelection

What to open, and how — already negotiated against the requested +ceiling. Carries available too, so a picker opened after a quality +change still describes the source correctly. +TRACES: UR-070, UR-079 | DR-225, DR-227

+
§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..43456455 --- /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..72dd8fe5 --- /dev/null +++ b/api/jellytau_lib/commands/player/enum.VideoSeekResponse.html @@ -0,0 +1,57 @@ +VideoSeekResponse in jellytau_lib::commands::player - RustSkip to main content

VideoSeekResponse

Enum VideoSeekResponse 

Source
pub enum VideoSeekResponse {
+    Native {
+        position: f64,
+    },
+    ReloadStream {
+        selection: StreamSelection,
+        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

§selection: StreamSelection

What to open, and how — transport included, so the frontend picks +its loader from a tagged enum rather than by searching the URL for +.m3u8. TRACES: UR-079 | DR-225

+
§seek_offset: f64

seek_offset carries the position to RESUME AT, not a base to add to +the element’s clock. The reloaded stream starts at the item’s zero — +a position on an HLS playlist makes the server 400 every segment +behind it (DR-181) — so the adapter reaches the position by seeking +the element and leaves the transcode offset at zero.

+

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..cce6a789 --- /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..ae1657dd --- /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..f2dfcdaf --- /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..f98da24b --- /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..a1e5b2ea --- /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..1f8dcfcd --- /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..93c53eb5 --- /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..0f5929fe --- /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..6f4420e1 --- /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-225 | UT-212

+
\ 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..817f97a6 --- /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..b9b420cb --- /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..bedfedaa --- /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..0065022b --- /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..6a4a4bde --- /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..1ddde753 --- /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..a37dd3f6 --- /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..bcc87448 --- /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..c278cdac --- /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..0c55ffa0 --- /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..1a7c48e4 --- /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..6628bec7 --- /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..52352751 --- /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..e00db56e --- /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..0442836e --- /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..e659826f --- /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..85b8eb9c --- /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..a575ce40 --- /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..34c16283 --- /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..7221d44a --- /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..65d0f0ae --- /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: 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..c2695b64 --- /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..b9fc1ca2 --- /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..9ad1f7c4 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_set_stream_quality.html @@ -0,0 +1,27 @@ +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>,
+    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 only. The in-player picker is a +“this film, this connection” control and its doc has always said so, but it +used to be implemented by writing the process-wide ceiling — so choosing +2 Mbps to get one awkward film moving silently capped every video played +afterwards for the rest of the process, with the Settings screen still +showing the old value and nothing in the UI admitting the change. It now +sets a per-playback override that the next item clears; the durable default +belongs to Settings, and player_set_video_settings is the one that writes +to the database.

+

TRACES: UR-074, UR-079 | DR-162, DR-226

+
\ 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..eb664928 --- /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..f7ccc927 --- /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..2f4c343a --- /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..94596093 --- /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..94cd799a --- /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..c1e77065 --- /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..e7369a92 --- /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..eb2d5846 --- /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..4f79e198 --- /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..c5fe2a46 --- /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..372cc077 --- /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..aad9b625 --- /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..d3a5c86e --- /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..674b84c2 --- /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..2dcfc88f --- /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..e5e8df90 --- /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..228d6ed7 --- /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..b189e1ca --- /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..f5630072 --- /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..efced496 --- /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..6eec553a --- /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..af1fcb1c --- /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..81cca346 --- /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..74e1c554 --- /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..6fa0c6be --- /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..565ed46c --- /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..6eabe82a --- /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..90fd5a29 --- /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..1743e22d --- /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..45447ed1 --- /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..0ada1bc8 --- /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..583a1796 --- /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..84611d48 --- /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..8e5552d4 --- /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..3c911ce3 --- /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..3d12e529 --- /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..8d27e814 --- /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..4db7fb8c --- /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..961c39ae --- /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..a4f51470 --- /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..08e2da03 --- /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..68d57be0 --- /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..8beb54eb --- /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..263dc7b2 --- /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..79f5a1ed --- /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..af75d7d7 --- /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..7f12173b --- /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..ba4c00c0 --- /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..2f29ad40 --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.PlayItemRequest.html @@ -0,0 +1,92 @@ +PlayItemRequest in jellytau_lib::commands::player - RustSkip to main content

PlayItemRequest

Struct PlayItemRequest 

Source
pub struct PlayItemRequest {
Show 13 fields + pub id: String, + pub title: String, + pub stream_url: String, + pub video_codec: String, + pub needs_transcoding: bool, + pub transport: Option<Transport>, + 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

+
§transport: Option<Transport>

How this item’s stream is fetched, as the backend decided it.

+

Carried on the queue item so a later seek/reload does not have to guess. +None for items queued by a path that never negotiated (audio tracks, +direct URLs) and for anything queued before this field existed, where the +caller falls back to needs_transcoding — every transcode this app +requests is HLS (DR-140), so that fallback is exact rather than a guess.

+

TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230

+
§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..cf006b24 --- /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..e108ccff --- /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..ccdf9c81 --- /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..7c271bfe --- /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..4af9ec23 --- /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..753fa5ae --- /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..92d5ab4b --- /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..f14d4e5a --- /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..fccbdd8f --- /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..1d69515b --- /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..bf690f58 --- /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..c1dab92f --- /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..407e4e36 --- /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..b9fb6d31 --- /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..39907ae6 --- /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..ce57a0e0 --- /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..17eb1e4a --- /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..cb5704fa --- /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..86225a40 --- /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..d9ebdc04 --- /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..3b92d035 --- /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..d85b1b8e --- /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..f0992aff --- /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..23491598 --- /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..a9559f68 --- /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..c2d380f9 --- /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..aa201e1a --- /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..cb2e03ed --- /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..aea4df2f --- /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..0f4e8800 --- /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..6c1693c3 --- /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..70e46076 --- /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..381a6dd5 --- /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..ad139576 --- /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..3df8a24f --- /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..017be6e9 --- /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..e10c0fc4 --- /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..6cb1b246 --- /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..e867172b --- /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..35107cc4 --- /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..f577b884 --- /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..1c91dc2f --- /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..26e5dbb9 --- /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..ab329c11 --- /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..e4d71f52 --- /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..05cff7b2 --- /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..d80ed48f --- /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..46b3a281 --- /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..3dd631a5 --- /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..e8798927 --- /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..abca9bd2 --- /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..22bac97f --- /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..d7b8d727 --- /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..6b7eeef2 --- /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..d765f57c --- /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..b953c9fc --- /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..f39788f5 --- /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..d2f52ffc --- /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..783b30a1 --- /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..97c70db3 --- /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..424676f3 --- /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_stream_selection.html b/api/jellytau_lib/commands/repository/fn.repository_get_stream_selection.html new file mode 100644 index 00000000..df71b3b5 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_stream_selection.html @@ -0,0 +1,17 @@ +repository_get_stream_selection in jellytau_lib::commands::repository - RustSkip to main content

repository_get_stream_selection

Function repository_get_stream_selection 

Source
pub async fn repository_get_stream_selection(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+    media_source_id: Option<String>,
+    audio_stream_index: Option<i32>,
+) -> Result<StreamSelection, String>
Expand description

Decide what stream to play for a video, and describe it.

+

Replaces repository_get_video_stream_url for playback. The returned +StreamSelection carries the transport explicitly, so the frontend picks +its loader from a tagged enum instead of testing the URL for .m3u8; and it +carries the quality ladder as it applies to this source, so the picker can +stop offering rungs that produce the same bytes as Original.

+

No start-position parameter, for the same reason as the URL builder: a +position on an HLS playlist is copied onto every segment URI and the server +rejects each with 400 (DR-181). Callers resume by seeking after load.

+

TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213

+
\ 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..512b3354 --- /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..232c28c2 --- /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..b647b506 --- /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..184ec437 --- /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..61fcee9a --- /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..2abe8190 --- /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..e3f943e7 --- /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..fa1a44ab --- /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..a2aa5530 --- /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..4fb182e9 --- /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..c4a15850 --- /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..85127f61 --- /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_stream_selection
Decide what stream to play for a video, and describe it.
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..f4aa9565 --- /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_stream_selection","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..98754b1b --- /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..96eba3d9 --- /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..4dcd9a37 --- /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..c7539d88 --- /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..0888420e --- /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..1fe72237 --- /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..acb45f83 --- /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..cc79d449 --- /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_selection.html b/api/jellytau_lib/commands/storage/fn.media_local_selection.html new file mode 100644 index 00000000..237cdd47 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.media_local_selection.html @@ -0,0 +1,13 @@ +media_local_selection in jellytau_lib::commands::storage - RustSkip to main content

media_local_selection

Function media_local_selection 

Source
pub fn media_local_selection(
+    server: State<'_, MediaServerWrapper>,
+    path: String,
+) -> Result<StreamSelection, String>
Expand description

The stream selection for a downloaded file.

+

The local-playback counterpart to repository_get_stream_selection. A file +on disk needs no negotiation — it is a direct play over a local transport, +with no quality ladder, because nothing about it can be re-negotiated — but +the frontend must not be the one to say so. It gets the same +[StreamSelection] shape as a streamed source so the player has one contract +to consume rather than two, and so no caller has to infer a transport from a +loopback URL.

+

TRACES: UR-071, UR-079 | DR-225

+
\ 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..f7351927 --- /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..db58938b --- /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..d624703d --- /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..52226c59 --- /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..03719366 --- /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..17a228ac --- /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..ec8b6db9 --- /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..98cf2803 --- /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..27b41cbe --- /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..ba8f1cb2 --- /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..99d68478 --- /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..f97ebffc --- /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..6dc7a656 --- /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..8d099480 --- /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..7abaf23e --- /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..f1baf7f5 --- /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..7e07519d --- /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..df479d21 --- /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..2fbac2a9 --- /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..8c0cab2d --- /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..2d71eeff --- /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..c509bd11 --- /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..2c8a979d --- /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..e303544b --- /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..68e25421 --- /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..6040f801 --- /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..56f9c523 --- /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..8fa40403 --- /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..d55e17c1 --- /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..b8a2d6e8 --- /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..1dcf963d --- /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_selection
The stream selection for a downloaded file.
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..60dd56c8 --- /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..5c57aa7f --- /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..07aaf56a --- /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..617b5a7c --- /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..676a0092 --- /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..0fe392ae --- /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..f47476f4 --- /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..efedec86 --- /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..3583bfbc --- /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..be1ae397 --- /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..66e3f14d --- /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..5e972610 --- /dev/null +++ b/api/jellytau_lib/commands/storage/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["media_local_selection","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..7dca1b39 --- /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..0f593e51 --- /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..bca9e1ac --- /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..64001b93 --- /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..c28de819 --- /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..5cd50162 --- /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..582c69f7 --- /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..293a9a5e --- /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..577ee14f --- /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..7aa9c0f6 --- /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..e92d5e40 --- /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..b5ebe6a7 --- /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..1ab05469 --- /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..82c74250 --- /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..fc48c4f6 --- /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..bcfb81d4 --- /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..c9c9d632 --- /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..d85c1608 --- /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..f9aa12f1 --- /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..ea9ef572 --- /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..124e2cdd --- /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..fb378afa --- /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..7e8a7670 --- /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..f6aae64d --- /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..79e09e58 --- /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..d3de6d6d --- /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..41066994 --- /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..e2de8675 --- /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..494ad4dc --- /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..5ac77d26 --- /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..aa745cf0 --- /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..5bb08684 --- /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..553fec22 --- /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..12d9ef73 --- /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..e9627a67 --- /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..6102d0ee --- /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..1c4f4a94 --- /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..69516fb4 --- /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..aa31b007 --- /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..2c0d82d5 --- /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..84d3d38a --- /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..59d0c602 --- /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..c53d25e7 --- /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..7f981cb9 --- /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..130fbf4b --- /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..3267d2de --- /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..df8bc5ce --- /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..48e121ce --- /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..8102b69e --- /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..6a17e4ca --- /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..90e2d77f --- /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..53f89c2e --- /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..ab9a5ffb --- /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..989f8faf --- /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..9ec99da2 --- /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..6164c73d --- /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..997e10f2 --- /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..1e3ef803 --- /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..b1396e16 --- /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..627a5468 --- /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..e8b34ab9 --- /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..0b676575 --- /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..3262a874 --- /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..f2277704 --- /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..88436010 --- /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..2c6e4f09 --- /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..cf207c26 --- /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..3ade45e1 --- /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..07157c27 --- /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..306a85e4 --- /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..83f0e79c --- /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..68135081 --- /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..2fbf43fb --- /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..4751af34 --- /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..aafc5534 --- /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..e970cd48 --- /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..4dcb6825 --- /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..99ed5f3b --- /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..c2df36cf --- /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..1b8167d3 --- /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..4604e6f9 --- /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..c052d4f8 --- /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..c25e5034 --- /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..e721c009 --- /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..11775be8 --- /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..ba9359a1 --- /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..499f1cf1 --- /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..93835075 --- /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..b3b7a63b --- /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..96c26047 --- /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..a0d62a2d --- /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..a7121fd2 --- /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..dc51d012 --- /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..599bbfc0 --- /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..64216ec7 --- /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..f76444d2 --- /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..257d5050 --- /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..1b6d427d --- /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..11b4924c --- /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..1de16b8b --- /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..aef61d3d --- /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..89520c7c --- /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..e6a9e4da --- /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..b920bfbf --- /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..3ffdb53d --- /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..ba878499 --- /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.engine_capabilities.html b/api/jellytau_lib/fn.engine_capabilities.html new file mode 100644 index 00000000..97dcc2dc --- /dev/null +++ b/api/jellytau_lib/fn.engine_capabilities.html @@ -0,0 +1,11 @@ +engine_capabilities in jellytau_lib - RustSkip to main content

engine_capabilities

Function engine_capabilities 

Source
pub(crate) fn engine_capabilities() -> Capabilities
Expand description

Construct the tauri-specta command builder. Shared by run() and the +bindings-export test so the TypeScript bindings always match the handler. +What the engine built for this platform can do.

+

Declared per engine, not per category. ExoPlayer speaks HLS and can seek a +server-side transcode in place; mpv cannot, because its HLS demuxer will not +make the server produce segments from a new offset. Grouping them as “native +engines” gets that backwards — being native is not the property that +matters, speaking HLS is — and treating a category as a proxy for an ability +is exactly the inference DR-246 removed.

+

TRACES: UR-081 | DR-246

+
\ 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..dfc23a3f --- /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..1e27e00c --- /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..930c2f6a --- /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..59efcb13 --- /dev/null +++ b/api/jellytau_lib/fn.specta_builder.html @@ -0,0 +1 @@ +specta_builder in jellytau_lib - RustSkip to main content

specta_builder

Function specta_builder 

Source
pub(crate) fn specta_builder() -> Builder<Wry>
\ 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..ccaa7d85 --- /dev/null +++ b/api/jellytau_lib/index.html @@ -0,0 +1,18 @@ +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.
engine_capabilities 🔒
Construct the tauri-specta command builder. Shared by run() and the +bindings-export test so the TypeScript bindings always match the handler. +What the engine built for this platform can do.
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 🔒
\ 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..896de267 --- /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..2fbb32e3 --- /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..4427c9bf --- /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..00660939 --- /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..aa895830 --- /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..10a2fa76 --- /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..ea147d81 --- /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..55105da6 --- /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..e65f25c3 --- /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..284f8144 --- /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..13ef4b28 --- /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..edebf3e9 --- /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..9e875f0e --- /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..8052fe2c --- /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..6d42e1f2 --- /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..1e7f9c25 --- /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..4713aa49 --- /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..dddcee3e --- /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..40a0840d --- /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..c6618b31 --- /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..5ed89687 --- /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..55582a2e --- /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..8dadb576 --- /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..db9bfef1 --- /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..178306e0 --- /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..da5e8949 --- /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..5f666f22 --- /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..0dbbae16 --- /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..aba0eee5 --- /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..79bc5a9f --- /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..0f214f56 --- /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..727473ab --- /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..415123b8 --- /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..fad226d0 --- /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..dc2b21e9 --- /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..e4c4ae1c --- /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..d714e8fd --- /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..49bf3742 --- /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..b9051d64 --- /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..24593bd5 --- /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..e17dc053 --- /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..0970e1be --- /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..5bc1c6c3 --- /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..5afd0491 --- /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..2937f3f1 --- /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..dd499975 --- /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..2db56577 --- /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..a88a4eac --- /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..e9590821 --- /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..8a5f797e --- /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..70dd0517 --- /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..49d06334 --- /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..60f229fe --- /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..e4aa9250 --- /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..2dec2cc8 --- /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..b19a13a6 --- /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..cb4a1afa --- /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..7a0de8ea --- /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..a0c37adb --- /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..8f8d3265 --- /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..a975152a --- /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..0f90e27c --- /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..73159f2f --- /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..ccd285fc --- /dev/null +++ b/api/jellytau_lib/player/backend/trait.PlayerBackend.html @@ -0,0 +1,98 @@ +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

+

Trait Implementations§

Source§

impl PlayerBackend for Box<dyn PlayerBackend>

Forward the trait through a box.

+

Box<dyn PlayerBackend> does not implement PlayerBackend on its own, so +without this the boxed engine built at the composition root cannot be handed +to anything generic over the trait — LegacyPlayer in particular.

+
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

Implementations on Foreign Types§

Source§

impl PlayerBackend for Box<dyn PlayerBackend>

Forward the trait through a box.

+

Box<dyn PlayerBackend> does not implement PlayerBackend on its own, so +without this the boxed engine built at the composition root cannot be handed +to anything generic over the trait — LegacyPlayer in particular.

+
Source§

fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>

Source§

fn play(&mut self) -> Result<(), PlayerError>

Source§

fn pause(&mut self) -> Result<(), PlayerError>

Source§

fn stop(&mut self) -> Result<(), PlayerError>

Source§

fn seek(&mut self, position: f64) -> Result<(), PlayerError>

Source§

fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>

Source§

fn position(&self) -> f64

Source§

fn duration(&self) -> Option<f64>

Source§

fn state(&self) -> PlayerState

Source§

fn volume(&self) -> f32

Source§

fn set_audio_settings( + &mut self, + settings: &AudioSettings, +) -> Result<(), PlayerError>

Source§

fn audio_settings(&self) -> AudioSettings

Source§

fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError>

Source§

fn set_subtitle_track( + &mut self, + stream_index: Option<i32>, +) -> Result<(), PlayerError>

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..cd221aa8 --- /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..a9ed3f41 --- /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..5e027215 --- /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..94c25510 --- /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..f3014294 --- /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..be9aa91e --- /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..c31f6ef9 --- /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..f20697c1 --- /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..39640bee --- /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..df1ac24b --- /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..8355fe1a --- /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..be71a8e8 --- /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..8e6be3ee --- /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..9f95933d --- /dev/null +++ b/api/jellytau_lib/player/index.html @@ -0,0 +1,8 @@ +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 legacy_player::LegacyPlayer;
pub use media::MediaItem;
pub use media::MediaSource;
pub use media::MediaType;
pub use media::QueueContext;
pub use media::SubtitleTrack;
pub use media_player::MediaPlayer;
pub use media_player::OpenRequest;
pub use media_player::Phase;
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.
legacy_player
A MediaPlayer over the old PlayerBackend trait.
media
media_player
The MediaPlayer contract: one API, interchangeable engines.
mpv_backend
mpv_player
MediaPlayer over libmpv.
mpv_render
mpv’s render API into a framebuffer we own (UR-080 / DR-231, IR-033).
native_video
Whether this process renders video natively — one answer, three consumers +(UR-080 / DR-231, DR-235). +Whether this process renders video natively, answered once.
queue
seek
Video seek strategy decision logic.
session
sleep_timer
state
stream_end
Telling a finished stream apart from a truncated one.
video_surface
The native video surface mpv renders into (UR-080 / DR-231).

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/legacy_player/index.html b/api/jellytau_lib/player/legacy_player/index.html new file mode 100644 index 00000000..2a395fe1 --- /dev/null +++ b/api/jellytau_lib/player/legacy_player/index.html @@ -0,0 +1,15 @@ +jellytau_lib::player::legacy_player - RustSkip to main content

Module legacy_player

Module legacy_player 

Source
Expand description

A MediaPlayer over the old PlayerBackend trait.

+

Two purposes.

+

Migration. Engines not yet ported — ExoPlayer, the webview element, the +null backend — keep working while PlayerController moves onto the new +contract (DR-245). Without this the port would have to land all four engines +at once.

+

Evidence. It reproduces exactly what every caller used to do: load, +then play, then seek for a start position. Running the conformance suite +against it therefore shows the old path failing the cases the new one passes, +on the same engine and the same media — which is the difference between +asserting that a design was wrong and demonstrating it.

+

It is deliberately a faithful reproduction, not a fixed-up one. Making it +pass would defeat the point.

+

TRACES: UR-081 | DR-245

+

Structs§

LegacyPlayer
\ No newline at end of file diff --git a/api/jellytau_lib/player/legacy_player/sidebar-items.js b/api/jellytau_lib/player/legacy_player/sidebar-items.js new file mode 100644 index 00000000..d1336770 --- /dev/null +++ b/api/jellytau_lib/player/legacy_player/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"struct":["LegacyPlayer"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/legacy_player/struct.LegacyPlayer.html b/api/jellytau_lib/player/legacy_player/struct.LegacyPlayer.html new file mode 100644 index 00000000..9146912c --- /dev/null +++ b/api/jellytau_lib/player/legacy_player/struct.LegacyPlayer.html @@ -0,0 +1,52 @@ +LegacyPlayer in jellytau_lib::player::legacy_player - RustSkip to main content

LegacyPlayer

Struct LegacyPlayer 

Source
pub struct LegacyPlayer<B: PlayerBackend> {
+    inner: B,
+    capabilities: Capabilities,
+    has_item: bool,
+}

Fields§

§inner: B§capabilities: Capabilities

Declared at construction: this wrapper is generic over engines with very +different abilities, and only the composition root knows which one it +just built. Guessing here would reintroduce exactly the inference DR-238 +removed.

+
§has_item: bool

The old trait has no notion of “opening”, so this is the best the wrapper +can do: it knows an item was handed over, not whether the engine is ready +for one. That gap is the whole problem.

+

Implementations§

Source§

impl<B: PlayerBackend> LegacyPlayer<B>

Source

pub fn new(inner: B, capabilities: Capabilities) -> Self

Trait Implementations§

Source§

impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B>

Source§

fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError>

Load, play, then seek — the sequence every caller used to write.

+

The seek is issued immediately, because a caller has no way to know when +the engine becomes ready. On an engine whose load is asynchronous it +fails and is discarded, and playback begins at zero: DR-241, reproduced.

+
Source§

fn set_muted(&mut self, _muted: bool) -> Result<(), PlayerError>

The old trait has no mute. Folding it into volume would lose the user’s +level, so this reports unsupported rather than pretending.

+
Source§

fn play(&mut self) -> Result<(), PlayerError>

Source§

fn pause(&mut self) -> Result<(), PlayerError>

Source§

fn close(&mut self) -> Result<(), PlayerError>

Stop and release the current item. Read more
Source§

fn seek(&mut self, to: Duration) -> Result<(), PlayerError>

Seek to an absolute position on the item’s own timeline. Read more
Source§

fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>

Source§

fn set_rate(&mut self, _rate: f64) -> Result<(), PlayerError>

Source§

fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>

Source§

fn select_subtitle_track( + &mut self, + index: Option<i32>, +) -> Result<(), PlayerError>

Source§

fn snapshot(&self) -> PlaybackSnapshot

One coherent read of the engine’s state.
Source§

fn set_audio_settings( + &mut self, + settings: &AudioSettings, +) -> Result<(), PlayerError>

Apply EQ, normalisation and gapless settings. Read more
Source§

fn audio_settings(&self) -> AudioSettings

Source§

fn capabilities(&self) -> Capabilities

Auto Trait Implementations§

§

impl<B> Freeze for LegacyPlayer<B>
where + B: Freeze,

§

impl<B> RefUnwindSafe for LegacyPlayer<B>
where + B: RefUnwindSafe,

§

impl<B> Send for LegacyPlayer<B>

§

impl<B> Sync for LegacyPlayer<B>

§

impl<B> Unpin for LegacyPlayer<B>
where + B: Unpin,

§

impl<B> UnsafeUnpin for LegacyPlayer<B>
where + B: UnsafeUnpin,

§

impl<B> UnwindSafe for LegacyPlayer<B>
where + B: UnwindSafe,

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/media/enum.MediaSource.html b/api/jellytau_lib/player/media/enum.MediaSource.html new file mode 100644 index 00000000..1ca92741 --- /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..244ba41e --- /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..e5f4144b --- /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..bf431014 --- /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..76c88424 --- /dev/null +++ b/api/jellytau_lib/player/media/struct.MediaItem.html @@ -0,0 +1,115 @@ +MediaItem in jellytau_lib::player::media - RustSkip to main content

MediaItem

Struct MediaItem 

Source
pub struct MediaItem {
Show 25 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 transport: Option<Transport>, + 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

+
§transport: Option<Transport>

How this item’s stream is fetched, as the backend decided it.

+

Carried on the queue item so a later seek/reload does not have to guess. +None for items queued by a path that never negotiated (audio tracks, +direct URLs) and for anything queued before this field existed, where the +caller falls back to needs_transcoding — every transcode this app +requests is HLS (DR-140), so that fallback is exact rather than a guess.

+

TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230

+
§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 playable_url(&self) -> String

The URL or path an engine should open.

+

TRACES: UR-081 | DR-245

+
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..8dc93de3 --- /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/media_player/enum.Phase.html b/api/jellytau_lib/player/media_player/enum.Phase.html new file mode 100644 index 00000000..bba65192 --- /dev/null +++ b/api/jellytau_lib/player/media_player/enum.Phase.html @@ -0,0 +1,55 @@ +Phase in jellytau_lib::player::media_player - RustSkip to main content

Phase

Enum Phase 

Source
pub enum Phase {
+    Idle,
+    Opening,
+    Ready,
+    Playing,
+    Paused,
+    Ended,
+    Failed(String),
+}
Expand description

What an engine is doing right now.

+

Opening is the state the previous design could not express, and is the +direct cause of DR-241: a seek that arrived while the engine had nothing +loaded had no phase to be queued against, so it was simply discarded and +playback began at zero.

+

Variants§

§

Idle

Nothing loaded. close() must reach this, and must be silent here.

+
§

Opening

An open is in flight. Position is not yet meaningful; a seek arriving +now must be honoured once the engine reaches Ready, never dropped.

+
§

Ready

Loaded and able to play, but not advancing.

+
§

Playing

§

Paused

§

Ended

Reached the end of the item by itself. Distinct from Idle, because +autoplay cares which one happened.

+
§

Failed(String)

Implementations§

Source§

impl Phase

Source

pub fn has_media(&self) -> bool

Whether the engine currently holds an item.

+
Source

pub fn is_active(&self) -> bool

Whether playback is advancing.

+

Trait Implementations§

Source§

impl Clone for Phase

Source§

fn clone(&self) -> Phase

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 Phase

Source§

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

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

impl PartialEq for Phase

Source§

fn eq(&self, other: &Phase) -> 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 Phase

Source§

impl StructuralPartialEq for Phase

Auto Trait Implementations§

§

impl Freeze for Phase

§

impl RefUnwindSafe for Phase

§

impl Send for Phase

§

impl Sync for Phase

§

impl Unpin for Phase

§

impl UnsafeUnpin for Phase

§

impl UnwindSafe for Phase

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/media_player/fn.duration_from_secs.html b/api/jellytau_lib/player/media_player/fn.duration_from_secs.html new file mode 100644 index 00000000..9db5a4c3 --- /dev/null +++ b/api/jellytau_lib/player/media_player/fn.duration_from_secs.html @@ -0,0 +1,11 @@ +duration_from_secs in jellytau_lib::player::media_player - RustSkip to main content

duration_from_secs

Function duration_from_secs 

Source
pub fn duration_from_secs(seconds: f64) -> Option<Duration>
Expand description

Seconds reported by an engine, as a Duration, without trusting the number.

+

Duration::from_secs_f64 panics on a negative or non-finite value, and +no engine promises otherwise. ExoPlayer reports C.TIME_UNSET — +Long::MIN_VALUE, about -9.2e15 — for a stream whose length it does not +know, which is every background-audio handoff: /Audio/{id}/universal is a +chunked, length-less transcode.

+

Held as a float that junk was harmless. Converted to a Duration it became +a panic that killed the backend mid-handoff and left a black screen with no +controls. Every engine crossing into this contract goes through here.

+

TRACES: UR-005 | DR-252

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

Module media_player

Module media_player 

Source
Expand description

The MediaPlayer contract: one API, interchangeable engines.

+

See docs/specs/media-player-controller.md.

+

This replaces PlayerBackend, which +abstracts a deviceload, then seek — rather than an intent. That +distinction is not academic; it produced four shipped defects in one day:

+
    +
  • A start position was not expressible, so every caller sequenced +load() + seek() itself and each raced the engine’s asynchronous load +independently. Resume worked through one caller and silently failed through +another (DR-241).
  • +
  • Whether a stream could be seeked in place was decided above the engines, +by a truth table in a command handler, for engines it does not own (DR-238).
  • +
  • Nothing in the contract obliged an engine to report its own state, so a +handler for mpv’s pause property sat unreachable and the play/pause +control never moved (DR-239).
  • +
+

The contract below is written so each of those is a compile-time or +conformance-time failure rather than a runtime surprise.

+

TRACES: UR-081 | DR-242

+

Structs§

Capabilities
What an engine can do, so callers adapt without naming engines.
OpenRequest
A request to present an item.
PlaybackSnapshot
Everything the UI consumes, read as one coherent value.

Enums§

Phase
What an engine is doing right now.

Traits§

MediaPlayer
Anything that can present media.

Functions§

duration_from_secs
Seconds reported by an engine, as a Duration, without trusting the number.
\ No newline at end of file diff --git a/api/jellytau_lib/player/media_player/sidebar-items.js b/api/jellytau_lib/player/media_player/sidebar-items.js new file mode 100644 index 00000000..88cd0aff --- /dev/null +++ b/api/jellytau_lib/player/media_player/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["Phase"],"fn":["duration_from_secs"],"struct":["Capabilities","OpenRequest","PlaybackSnapshot"],"trait":["MediaPlayer"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/media_player/struct.Capabilities.html b/api/jellytau_lib/player/media_player/struct.Capabilities.html new file mode 100644 index 00000000..356ed0dc --- /dev/null +++ b/api/jellytau_lib/player/media_player/struct.Capabilities.html @@ -0,0 +1,72 @@ +Capabilities in jellytau_lib::player::media_player - RustSkip to main content

Capabilities

Struct Capabilities 

Source
pub struct Capabilities {
+    pub video: bool,
+    pub audio_settings: bool,
+    pub subtitle_switching: bool,
+    pub audio_track_switching: bool,
+    pub seeks_transcoded_in_place: bool,
+}
Expand description

What an engine can do, so callers adapt without naming engines.

+

If a caller ever branches on which engine it holds, this struct is missing +something — add it here rather than sniffing. Engine identity leaking into +callers is the coupling DR-238 came from.

+

Fields§

§video: bool

The engine renders pictures, not only sound.

+
§audio_settings: bool

Audio settings (EQ, normalisation, gapless) are honoured.

+
§subtitle_switching: bool

Subtitle tracks can be selected without re-opening.

+
§audio_track_switching: bool

Audio tracks can be selected without re-opening.

+
§seeks_transcoded_in_place: bool

A server-side transcode can be seeked without re-opening the stream.

+

True for hls.js, which seeks within the VOD playlist it is handed and +lets the server catch up. False for mpv, whose HLS demuxer cannot make +the server transcode from a new offset.

+

Declared by the engine rather than inferred by the caller. The previous +design decided this from is_hls and use_html5 in a command handler — +on behalf of engines it did not own — which is how “who renders” came to +mean “how do I seek” and why a transcoded seek silently did nothing the +moment native video changed the renderer (DR-238).

+

Re-negotiating a stream needs the repository, which sits above the +engine, so the engine states the capability and the caller acts on it.

+

Implementations§

Source§

impl Capabilities

Source

pub fn mpv() -> Self

mpv.

+

Cannot seek a server-side transcode in place: its HLS demuxer will not +make the server produce segments from a new offset, so the stream has to +be re-opened.

+
Source

pub fn exoplayer() -> Self

ExoPlayer.

+

Can seek a transcode in place. It is a full HLS client, so like +hls.js it seeks within the VOD playlist it was handed and lets the +server catch up. Grouping it with mpv as “a native engine” gets this +exactly backwards — being native is not the property that matters here, +speaking HLS is, and that is the whole reason this is declared per +engine rather than inferred from a category.

+
Source

pub fn webview() -> Self

An engine that renders through the webview element, where hls.js seeks +within the playlist it was handed.

+

Trait Implementations§

Source§

impl Clone for Capabilities

Source§

fn clone(&self) -> Capabilities

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 Capabilities

Source§

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

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

impl PartialEq for Capabilities

Source§

fn eq(&self, other: &Capabilities) -> 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 Capabilities

Source§

impl Eq for Capabilities

Source§

impl StructuralPartialEq for Capabilities

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/media_player/struct.OpenRequest.html b/api/jellytau_lib/player/media_player/struct.OpenRequest.html new file mode 100644 index 00000000..30d78f06 --- /dev/null +++ b/api/jellytau_lib/player/media_player/struct.OpenRequest.html @@ -0,0 +1,42 @@ +OpenRequest in jellytau_lib::player::media_player - RustSkip to main content

OpenRequest

Struct OpenRequest 

Source
pub struct OpenRequest {
+    pub media: MediaItem,
+    pub selection: StreamSelection,
+    pub start: Duration,
+    pub audio_track: Option<i32>,
+    pub subtitle_track: Option<i32>,
+    pub autoplay: bool,
+}
Expand description

A request to present an item.

+

start is the reason this type exists. Carrying it here — rather than +leaving callers to seek after open — is what closes the load/seek race, +because the engine is the only layer that knows when its pipeline can accept +a position.

+

Fields§

§media: MediaItem§selection: StreamSelection§start: Duration

Where to begin. Duration::ZERO means the start of the item.

+
§audio_track: Option<i32>§subtitle_track: Option<i32>§autoplay: bool

Begin playing as soon as the engine is able.

+

Implementations§

Source§

impl OpenRequest

Source

pub fn new(media: MediaItem, selection: StreamSelection) -> Self

Open at the beginning, playing.

+
Source

pub fn starting_at(self, start: Duration) -> Self

Trait Implementations§

Source§

impl Clone for OpenRequest

Source§

fn clone(&self) -> OpenRequest

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 OpenRequest

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/media_player/struct.PlaybackSnapshot.html b/api/jellytau_lib/player/media_player/struct.PlaybackSnapshot.html new file mode 100644 index 00000000..ca3fdb7f --- /dev/null +++ b/api/jellytau_lib/player/media_player/struct.PlaybackSnapshot.html @@ -0,0 +1,45 @@ +PlaybackSnapshot in jellytau_lib::player::media_player - RustSkip to main content

PlaybackSnapshot

Struct PlaybackSnapshot 

Source
pub struct PlaybackSnapshot {
+    pub phase: Phase,
+    pub position: Duration,
+    pub duration: Option<Duration>,
+    pub seekable: bool,
+    pub volume: f32,
+    pub muted: bool,
+    pub rate: f64,
+    pub audio_track: Option<i32>,
+    pub subtitle_track: Option<i32>,
+}
Expand description

Everything the UI consumes, read as one coherent value.

+

Deliberately a single snapshot rather than a dozen getters: reading position +and duration through separate calls is how a paused player reported +<position> / 0.0 when a file unloaded between them.

+

Fields§

§phase: Phase§position: Duration§duration: Option<Duration>

None while unknown — a live stream, or an item still opening.

+
§seekable: bool

Whether seek can be expected to land. False for live edges.

+
§volume: f32

0.0 – 1.0.

+
§muted: bool§rate: f64§audio_track: Option<i32>§subtitle_track: Option<i32>

Trait Implementations§

Source§

impl Clone for PlaybackSnapshot

Source§

fn clone(&self) -> PlaybackSnapshot

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 PlaybackSnapshot

Source§

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

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

impl Default for PlaybackSnapshot

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/player/media_player/trait.MediaPlayer.html b/api/jellytau_lib/player/media_player/trait.MediaPlayer.html new file mode 100644 index 00000000..03f83a2c --- /dev/null +++ b/api/jellytau_lib/player/media_player/trait.MediaPlayer.html @@ -0,0 +1,58 @@ +MediaPlayer in jellytau_lib::player::media_player - RustSkip to main content

MediaPlayer

Trait MediaPlayer 

Source
pub trait MediaPlayer: Send {
+
Show 14 methods // Required methods + fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError>; + fn play(&mut self) -> Result<(), PlayerError>; + fn pause(&mut self) -> Result<(), PlayerError>; + fn close(&mut self) -> Result<(), PlayerError>; + fn seek(&mut self, to: Duration) -> Result<(), PlayerError>; + fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>; + fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError>; + fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError>; + fn select_audio_track( + &mut self, + index: Option<i32>, + ) -> Result<(), PlayerError>; + fn select_subtitle_track( + &mut self, + index: Option<i32>, + ) -> Result<(), PlayerError>; + fn snapshot(&self) -> PlaybackSnapshot; + fn capabilities(&self) -> Capabilities; + + // Provided methods + fn set_audio_settings( + &mut self, + _settings: &AudioSettings, + ) -> Result<(), PlayerError> { ... } + fn audio_settings(&self) -> AudioSettings { ... } +
}
Expand description

Anything that can present media.

+

Implementations: MpvPlayer (Linux/Windows), ExoPlayerPlayer (Android), +WebviewPlayer (HTML5 element), and FakePlayer for tests. Every one of +them must pass [super::conformance].

+

Required Methods§

Source

fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError>

Present req.selection, beginning at req.start.

+

One operation, deliberately. An engine that cannot start at an offset +natively absorbs that internally — by deferring until loaded, or by +re-opening — because it is the only layer that knows when it can. +Callers must never follow open with a seek to achieve a start +position; that is the bug this signature exists to prevent.

+
Source

fn play(&mut self) -> Result<(), PlayerError>

Source

fn pause(&mut self) -> Result<(), PlayerError>

Source

fn close(&mut self) -> Result<(), PlayerError>

Stop and release the current item.

+

Must be idempotent and must leave the engine silent. “Stopped” +and “producing no audio” were not the same thing in the previous design, +and the gap between them is audible.

+
Source

fn seek(&mut self, to: Duration) -> Result<(), PlayerError>

Seek to an absolute position on the item’s own timeline.

+

Whether that is an in-place seek or a re-open of the stream is the +engine’s business: hls.js seeks within a VOD playlist, mpv’s HLS demuxer +cannot make a server transcode from a new offset. Callers state the +destination and nothing else.

+
Source

fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>

Source

fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError>

Source

fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError>

Source

fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>

Source

fn select_subtitle_track( + &mut self, + index: Option<i32>, +) -> Result<(), PlayerError>

Source

fn snapshot(&self) -> PlaybackSnapshot

One coherent read of the engine’s state.

+
Source

fn capabilities(&self) -> Capabilities

Provided Methods§

Source

fn set_audio_settings( + &mut self, + _settings: &AudioSettings, +) -> Result<(), PlayerError>

Apply EQ, normalisation and gapless settings.

+

Provided rather than required: engines that cannot honour them say so +through Capabilities::audio_settings and inherit this no-op, instead +of every implementation carrying an Ok(()) it does not mean.

+
Source

fn audio_settings(&self) -> AudioSettings

Implementors§

\ 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..4cd1fa5e --- /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..000d54e8 --- /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..1c960a6f --- /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..f5860779 --- /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..82558458 --- /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..95e06574 --- /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..2d43d0f9 --- /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/fn.registered_handle.html b/api/jellytau_lib/player/mpv_backend/fn.registered_handle.html new file mode 100644 index 00000000..8bc4db2d --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/fn.registered_handle.html @@ -0,0 +1,4 @@ +registered_handle in jellytau_lib::player::mpv_backend - RustSkip to main content

registered_handle

Function registered_handle 

Source
pub fn registered_handle() -> *mut mpv_handle
Expand description

The registered handle, or null if no MPV backend was created (initialisation +can fail, and the app falls back to a no-op backend rather than dying).

+

TRACES: UR-080 | DR-231

+
\ 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..392026f6 --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/index.html @@ -0,0 +1,11 @@ +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.

Statics§

MPV_HANDLE 🔒
The mpv handle of the backend this process created, for the video surface.

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.
registered_handle
The registered handle, or null if no MPV backend was created (initialisation +can fail, and the app falls back to a no-op backend rather than dying).
\ 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..cc2415bc --- /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","registered_handle"],"static":["MPV_HANDLE"],"struct":["InternalState","MpvBackend"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_backend/static.MPV_HANDLE.html b/api/jellytau_lib/player/mpv_backend/static.MPV_HANDLE.html new file mode 100644 index 00000000..7841315f --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/static.MPV_HANDLE.html @@ -0,0 +1,11 @@ +MPV_HANDLE in jellytau_lib::player::mpv_backend - RustSkip to main content

MPV_HANDLE

Static MPV_HANDLE 

Source
static MPV_HANDLE: OnceLock<usize>
Expand description

The mpv handle of the backend this process created, for the video surface.

+

A OnceLock rather than a field reached through PlayerBackend, because the +trait is cross-platform and a raw mpv pointer is not something every backend +should have to pretend to have. Stored as usize because a raw pointer is +neither Send nor Sync; the only consumer is the GTK main thread, which is +also where mpv was created.

+

Written once at construction and never cleared: the backend outlives the +window, so there is no window in which this could dangle while a surface is +still using it.

+

TRACES: UR-080 | DR-231

+
\ 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..c553c2b2 --- /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..05fa1565 --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/struct.MpvBackend.html @@ -0,0 +1,79 @@ +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>>,
+    pending_seek: Arc<Mutex<Option<f64>>>,
+}
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.

+
§pending_seek: Arc<Mutex<Option<f64>>>

A seek that arrived before MPV had a file to seek in.

+

loadfile is asynchronous: it returns as soon as the command is queued, +so time-pos is not yet a resolvable property and setting it fails. A +seek issued in that window used to be dropped on the floor, and the two +callers that do exactly this are the ones a viewer notices — resume, and +a transcoded seek, both of which re-open the stream and then ask for a +position. The stream reloaded and played from zero.

+

Held here and applied by the FileLoaded arm.

+

TRACES: UR-040, UR-005 | DR-241

+

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/mpv_player/enum.Output.html b/api/jellytau_lib/player/mpv_player/enum.Output.html new file mode 100644 index 00000000..3ac763e2 --- /dev/null +++ b/api/jellytau_lib/player/mpv_player/enum.Output.html @@ -0,0 +1,41 @@ +Output in jellytau_lib::player::mpv_player - RustSkip to main content

Output

Enum Output 

Source
pub enum Output {
+    Real,
+    Null,
+}
Expand description

How the engine should talk to the machine.

+

Variants§

§

Real

Real audio and video. What the app uses.

+
§

Null

No audio device, no window. What conformance uses, so the suite can run +on a headless runner without claiming the user’s speakers.

+

Trait Implementations§

Source§

impl Clone for Output

Source§

fn clone(&self) -> Output

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 Output

Source§

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

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

impl PartialEq for Output

Source§

fn eq(&self, other: &Output) -> 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 Output

Source§

impl Eq for Output

Source§

impl StructuralPartialEq for Output

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/mpv_player/index.html b/api/jellytau_lib/player/mpv_player/index.html new file mode 100644 index 00000000..19d9ab10 --- /dev/null +++ b/api/jellytau_lib/player/mpv_player/index.html @@ -0,0 +1,11 @@ +jellytau_lib::player::mpv_player - RustSkip to main content

Module mpv_player

Module mpv_player 

Source
Expand description

MediaPlayer over libmpv.

+

The point of difference from MpvBackend is MpvPlayer::open: the start +position is applied at load time, via mpv’s own start option, instead +of being seeked to afterwards. loadfile is asynchronous, so a seek issued +after it targets a player that has nothing loaded, fails, and — under the old +contract — was discarded. That is DR-241, and it is why resume and transcoded +skip both played from zero.

+

A seek arriving during Phase::Opening is held and applied when the file +loads, so no caller has to know where that window begins or ends.

+

TRACES: UR-081, UR-040, UR-005 | DR-244

+

Structs§

MpvPlayer
Shared 🔒
State the event thread writes and the caller reads.

Enums§

Output
How the engine should talk to the machine.
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_player/sidebar-items.js b/api/jellytau_lib/player/mpv_player/sidebar-items.js new file mode 100644 index 00000000..f6c855dd --- /dev/null +++ b/api/jellytau_lib/player/mpv_player/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["Output"],"struct":["MpvPlayer","Shared"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_player/struct.MpvPlayer.html b/api/jellytau_lib/player/mpv_player/struct.MpvPlayer.html new file mode 100644 index 00000000..6a23846b --- /dev/null +++ b/api/jellytau_lib/player/mpv_player/struct.MpvPlayer.html @@ -0,0 +1,38 @@ +MpvPlayer in jellytau_lib::player::mpv_player - RustSkip to main content

MpvPlayer

Struct MpvPlayer 

Source
pub struct MpvPlayer {
+    mpv: Arc<Mpv>,
+    shared: Arc<Mutex<Shared>>,
+    volume: f32,
+    muted: bool,
+    rate: f64,
+    audio_track: Option<i32>,
+    subtitle_track: Option<i32>,
+}

Fields§

§mpv: Arc<Mpv>§shared: Arc<Mutex<Shared>>§volume: f32§muted: bool§rate: f64§audio_track: Option<i32>§subtitle_track: Option<i32>

Implementations§

Trait Implementations§

Source§

impl MediaPlayer for MpvPlayer

Source§

fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError>

Present req.selection, beginning at req.start. Read more
Source§

fn play(&mut self) -> Result<(), PlayerError>

Source§

fn pause(&mut self) -> Result<(), PlayerError>

Source§

fn close(&mut self) -> Result<(), PlayerError>

Stop and release the current item. Read more
Source§

fn seek(&mut self, to: Duration) -> Result<(), PlayerError>

Seek to an absolute position on the item’s own timeline. Read more
Source§

fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>

Source§

fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError>

Source§

fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError>

Source§

fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>

Source§

fn select_subtitle_track( + &mut self, + index: Option<i32>, +) -> Result<(), PlayerError>

Source§

fn snapshot(&self) -> PlaybackSnapshot

One coherent read of the engine’s state.
Source§

fn capabilities(&self) -> Capabilities

Source§

fn set_audio_settings( + &mut self, + _settings: &AudioSettings, +) -> Result<(), PlayerError>

Apply EQ, normalisation and gapless settings. Read more
Source§

fn audio_settings(&self) -> 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> 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_player/struct.Shared.html b/api/jellytau_lib/player/mpv_player/struct.Shared.html new file mode 100644 index 00000000..a92f9a15 --- /dev/null +++ b/api/jellytau_lib/player/mpv_player/struct.Shared.html @@ -0,0 +1,36 @@ +Shared in jellytau_lib::player::mpv_player - RustSkip to main content

Shared

Struct Shared 

Source
struct Shared {
+    phase: Phase,
+    position: Duration,
+    duration: Option<Duration>,
+    seekable: bool,
+    deferred_seek: Option<Duration>,
+    open_generation: u64,
+}
Expand description

State the event thread writes and the caller reads.

+

Fields§

§phase: Phase§position: Duration§duration: Option<Duration>§seekable: bool§deferred_seek: Option<Duration>

A seek that arrived while opening. Applied on FileLoaded.

+
§open_generation: u64

Cleared by close(), so an open still in flight cannot come back to life +and start playing after the caller has stopped it.

+

Trait Implementations§

Source§

impl Debug for Shared

Source§

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

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

impl Default for Shared

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/mpv_render/constant.GL_COLOR_ATTACHMENT0.html b/api/jellytau_lib/player/mpv_render/constant.GL_COLOR_ATTACHMENT0.html new file mode 100644 index 00000000..c16af181 --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/constant.GL_COLOR_ATTACHMENT0.html @@ -0,0 +1 @@ +GL_COLOR_ATTACHMENT0 in jellytau_lib::player::mpv_render - RustSkip to main content

GL_COLOR_ATTACHMENT0

Constant GL_COLOR_ATTACHMENT0 

Source
const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_render/constant.GL_FRAMEBUFFER.html b/api/jellytau_lib/player/mpv_render/constant.GL_FRAMEBUFFER.html new file mode 100644 index 00000000..9f7ed28f --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/constant.GL_FRAMEBUFFER.html @@ -0,0 +1 @@ +GL_FRAMEBUFFER in jellytau_lib::player::mpv_render - RustSkip to main content

GL_FRAMEBUFFER

Constant GL_FRAMEBUFFER 

Source
const GL_FRAMEBUFFER: u32 = 0x8D40;
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_render/constant.GL_FRAMEBUFFER_COMPLETE.html b/api/jellytau_lib/player/mpv_render/constant.GL_FRAMEBUFFER_COMPLETE.html new file mode 100644 index 00000000..aa5edcf4 --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/constant.GL_FRAMEBUFFER_COMPLETE.html @@ -0,0 +1 @@ +GL_FRAMEBUFFER_COMPLETE in jellytau_lib::player::mpv_render - RustSkip to main content

GL_FRAMEBUFFER_COMPLETE

Constant GL_FRAMEBUFFER_COMPLETE 

Source
const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_render/constant.GL_LINEAR.html b/api/jellytau_lib/player/mpv_render/constant.GL_LINEAR.html new file mode 100644 index 00000000..fc3df364 --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/constant.GL_LINEAR.html @@ -0,0 +1 @@ +GL_LINEAR in jellytau_lib::player::mpv_render - RustSkip to main content

GL_LINEAR

Constant GL_LINEAR 

Source
const GL_LINEAR: c_int = 0x2601;
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_render/constant.GL_RGBA.html b/api/jellytau_lib/player/mpv_render/constant.GL_RGBA.html new file mode 100644 index 00000000..58aad138 --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/constant.GL_RGBA.html @@ -0,0 +1 @@ +GL_RGBA in jellytau_lib::player::mpv_render - RustSkip to main content

GL_RGBA

Constant GL_RGBA 

Source
const GL_RGBA: u32 = 0x1908;
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_render/constant.GL_RGBA8.html b/api/jellytau_lib/player/mpv_render/constant.GL_RGBA8.html new file mode 100644 index 00000000..ea0049f9 --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/constant.GL_RGBA8.html @@ -0,0 +1 @@ +GL_RGBA8 in jellytau_lib::player::mpv_render - RustSkip to main content

GL_RGBA8

Constant GL_RGBA8 

Source
const GL_RGBA8: c_int = 0x8058;
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_render/constant.GL_TEXTURE_2D.html b/api/jellytau_lib/player/mpv_render/constant.GL_TEXTURE_2D.html new file mode 100644 index 00000000..248f4dc7 --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/constant.GL_TEXTURE_2D.html @@ -0,0 +1 @@ +GL_TEXTURE_2D in jellytau_lib::player::mpv_render - RustSkip to main content

GL_TEXTURE_2D

Constant GL_TEXTURE_2D 

Source
const GL_TEXTURE_2D: u32 = 0x0DE1;
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_render/constant.GL_TEXTURE_MAG_FILTER.html b/api/jellytau_lib/player/mpv_render/constant.GL_TEXTURE_MAG_FILTER.html new file mode 100644 index 00000000..e854ac9d --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/constant.GL_TEXTURE_MAG_FILTER.html @@ -0,0 +1 @@ +GL_TEXTURE_MAG_FILTER in jellytau_lib::player::mpv_render - RustSkip to main content

GL_TEXTURE_MAG_FILTER

Constant GL_TEXTURE_MAG_FILTER 

Source
const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_render/constant.GL_TEXTURE_MIN_FILTER.html b/api/jellytau_lib/player/mpv_render/constant.GL_TEXTURE_MIN_FILTER.html new file mode 100644 index 00000000..6ae21509 --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/constant.GL_TEXTURE_MIN_FILTER.html @@ -0,0 +1 @@ +GL_TEXTURE_MIN_FILTER in jellytau_lib::player::mpv_render - RustSkip to main content

GL_TEXTURE_MIN_FILTER

Constant GL_TEXTURE_MIN_FILTER 

Source
const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_render/constant.GL_UNSIGNED_BYTE.html b/api/jellytau_lib/player/mpv_render/constant.GL_UNSIGNED_BYTE.html new file mode 100644 index 00000000..55d25437 --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/constant.GL_UNSIGNED_BYTE.html @@ -0,0 +1 @@ +GL_UNSIGNED_BYTE in jellytau_lib::player::mpv_render - RustSkip to main content

GL_UNSIGNED_BYTE

Constant GL_UNSIGNED_BYTE 

Source
const GL_UNSIGNED_BYTE: u32 = 0x1401;
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_render/fn.get_proc_address.html b/api/jellytau_lib/player/mpv_render/fn.get_proc_address.html new file mode 100644 index 00000000..a4380be7 --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/fn.get_proc_address.html @@ -0,0 +1,5 @@ +get_proc_address in jellytau_lib::player::mpv_render - RustSkip to main content

get_proc_address

Function get_proc_address 

Source
unsafe extern "C" fn get_proc_address(
+    _ctx: *mut c_void,
+    name: *const c_char,
+) -> *mut c_void
Expand description

What mpv calls to find GL entry points. Same rule as resolve.

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

resolve

Function resolve 

Source
unsafe fn resolve(name: &str) -> *mut c_void
Expand description

Resolve a GL symbol the way libepoxy actually exports it.

+

This is the trap that cost the spike a debugging cycle. libepoxy does not +export glFoo as a function. It exports epoxy_glFoo as a data symbol +holding a lazily-resolving function pointer. So the address dlsym returns +is the address of the pointer, not of any code: returning it makes mpv jump +into non-executable data and take SIGSEGV/SEGV_ACCERR on the very first GL +call. The value must be read out of that location.

+

The epoxy crate does this correctly and is unusable here — its +gl_generator dependency pulls a yanked xml-rs.

+

TRACES: UR-080 | IR-033

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

Module mpv_render

Module mpv_render 

Source
Expand description

mpv’s render API into a framebuffer we own (UR-080 / DR-231, IR-033).

+

Deliberately not GTK-gated beyond the platform that currently builds it: +everything here is the portable half, and Windows reuses it unchanged behind +its own surface. +mpv’s render API, driven into an OpenGL framebuffer we own.

+

This is the half of native video that is not GTK: create a render context +over the mpv handle the audio backend already drives, render a frame into a +texture, and hand that texture id back for the toolkit to composite.

+

Kept apart from video_surface deliberately — everything here is portable +across the platforms this app targets, while the surface that consumes it is +not. Windows reuses this file unchanged (DR-237).

+

TRACES: UR-080 | DR-231, DR-232, IR-033

+

Macros§

load 🔒

Structs§

Gl 🔒
GL entry points, resolved once.
MpvRenderContext
mpv’s render context plus the framebuffer it draws into.
Target 🔒
A colour-renderable framebuffer mpv draws into, sized to the widget.

Constants§

GL_COLOR_ATTACHMENT0 🔒
GL_FRAMEBUFFER 🔒
GL_FRAMEBUFFER_COMPLETE 🔒
GL_LINEAR 🔒
GL_RGBA 🔒
GL_RGBA8 🔒
GL_TEXTURE_2D 🔒
GL_TEXTURE_MAG_FILTER 🔒
GL_TEXTURE_MIN_FILTER 🔒
GL_UNSIGNED_BYTE 🔒

Functions§

get_proc_address 🔒
What mpv calls to find GL entry points. Same rule as resolve.
resolve 🔒
Resolve a GL symbol the way libepoxy actually exports it.
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_render/macro.load!.html b/api/jellytau_lib/player/mpv_render/macro.load!.html new file mode 100644 index 00000000..e4d1b1fe --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/macro.load!.html @@ -0,0 +1,11 @@ + + + + + Redirection + + +

Redirecting to macro.load.html...

+ + + \ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_render/macro.load.html b/api/jellytau_lib/player/mpv_render/macro.load.html new file mode 100644 index 00000000..84fa96e3 --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/macro.load.html @@ -0,0 +1,3 @@ +load in jellytau_lib::player::mpv_render - RustSkip to main content

load

Macro load 

Source
macro_rules! load {
+    ($name:literal) => { ... };
+}
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_render/sidebar-items.js b/api/jellytau_lib/player/mpv_render/sidebar-items.js new file mode 100644 index 00000000..c445ce42 --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["GL_COLOR_ATTACHMENT0","GL_FRAMEBUFFER","GL_FRAMEBUFFER_COMPLETE","GL_LINEAR","GL_RGBA","GL_RGBA8","GL_TEXTURE_2D","GL_TEXTURE_MAG_FILTER","GL_TEXTURE_MIN_FILTER","GL_UNSIGNED_BYTE"],"fn":["get_proc_address","resolve"],"macro":[["load",1]],"struct":["Gl","MpvRenderContext","Target"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_render/struct.Gl.html b/api/jellytau_lib/player/mpv_render/struct.Gl.html new file mode 100644 index 00000000..37d8bc04 --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/struct.Gl.html @@ -0,0 +1,43 @@ +Gl in jellytau_lib::player::mpv_render - RustSkip to main content

Gl

Struct Gl 

Source
struct Gl {
+    gen_framebuffers: unsafe extern "C" fn(c_int, *mut u32),
+    delete_framebuffers: unsafe extern "C" fn(c_int, *const u32),
+    bind_framebuffer: unsafe extern "C" fn(u32, u32),
+    framebuffer_texture_2d: unsafe extern "C" fn(u32, u32, u32, u32, c_int),
+    gen_textures: unsafe extern "C" fn(c_int, *mut u32),
+    delete_textures: unsafe extern "C" fn(c_int, *const u32),
+    bind_texture: unsafe extern "C" fn(u32, u32),
+    tex_image_2d: unsafe extern "C" fn(u32, c_int, c_int, c_int, c_int, c_int, u32, u32, *const c_void),
+    tex_parameteri: unsafe extern "C" fn(u32, u32, c_int),
+    check_framebuffer_status: unsafe extern "C" fn(u32) -> u32,
+}
Expand description

GL entry points, resolved once.

+

Only the handful needed to own a framebuffer; mpv resolves everything else +it needs through get_proc_address.

+

Fields§

§gen_framebuffers: unsafe extern "C" fn(c_int, *mut u32)§delete_framebuffers: unsafe extern "C" fn(c_int, *const u32)§bind_framebuffer: unsafe extern "C" fn(u32, u32)§framebuffer_texture_2d: unsafe extern "C" fn(u32, u32, u32, u32, c_int)§gen_textures: unsafe extern "C" fn(c_int, *mut u32)§delete_textures: unsafe extern "C" fn(c_int, *const u32)§bind_texture: unsafe extern "C" fn(u32, u32)§tex_image_2d: unsafe extern "C" fn(u32, c_int, c_int, c_int, c_int, c_int, u32, u32, *const c_void)§tex_parameteri: unsafe extern "C" fn(u32, u32, c_int)§check_framebuffer_status: unsafe extern "C" fn(u32) -> u32

Implementations§

Source§

impl Gl

Source

unsafe fn load() -> Option<Self>

Resolve every entry point, or none — a partially-loaded table would fail +later at a call site with no context.

+

The transmutes are unannotated on purpose: each target type is declared +once on the struct field above, and repeating it at the call site would +be two places to get the same signature wrong.

+

Auto Trait Implementations§

§

impl Freeze for Gl

§

impl RefUnwindSafe for Gl

§

impl Send for Gl

§

impl Sync for Gl

§

impl Unpin for Gl

§

impl UnsafeUnpin for Gl

§

impl UnwindSafe for Gl

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_render/struct.MpvRenderContext.html b/api/jellytau_lib/player/mpv_render/struct.MpvRenderContext.html new file mode 100644 index 00000000..0ed7f510 --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/struct.MpvRenderContext.html @@ -0,0 +1,78 @@ +MpvRenderContext in jellytau_lib::player::mpv_render - RustSkip to main content

MpvRenderContext

Struct MpvRenderContext 

Source
pub struct MpvRenderContext {
+    ctx: *mut mpv_render_context,
+    gl: Gl,
+    target: Option<Target>,
+}
Expand description

mpv’s render context plus the framebuffer it draws into.

+

§Lifetime (DR-232)

+

The render context must not outlive the GL context it was created against. +Drop unregisters mpv’s update callback before freeing the context, so a +callback cannot land on a freed pointer, and frees the GL objects while the +caller still has the context current. The caller is responsible for making +the GL context current around both creation and drop — see video_surface.

+

This is DR-184 on Android restated: a surface outliving its player. The spike +had no defence at all and saw one unexplained SIGSEGV in a decoder thread.

+

Fields§

§ctx: *mut mpv_render_context§gl: Gl§target: Option<Target>

Implementations§

Source§

impl MpvRenderContext

Source

pub unsafe fn new(mpv: *mut mpv_handle) -> Option<Self>

Create a render context over an existing mpv handle.

+

The GL context must already be current on this thread.

+

TRACES: UR-080 | DR-231, IR-033

+
Source

pub unsafe fn set_update_callback( + &mut self, + callback: mpv_render_update_fn, + ctx: *mut c_void, +)

Ask to be told when a new frame is ready.

+

Paired with report_swap: without both, mpv has +nothing to time against. The symptom is misleading — playback looks fine +in a window and judders at fullscreen, which reads as a compositing or +GPU limit and is neither (DR-233).

+

TRACES: UR-080 | DR-233

+
Source

pub unsafe fn has_frame(&self) -> bool

Whether mpv has a new frame waiting.

+

Asked of mpv directly rather than inferred from its update callback, and +that distinction is the whole of frame pacing here:

+
    +
  • Waiting only on the callback deadlocks — mpv will not progress until +the client renders, so if the client will not render until mpv says +so, neither moves. That presents as a file that loads, shows one +frame, and then sits silent.
  • +
  • Rendering on every frame-clock tick regardless is the opposite +error: report_swap then claims a presentation far more often than +real frames exist, mpv has nothing coherent to time against, and +playback judders badly.
  • +
+

Polling is neither. It runs on the main thread, costs a single atomic +read inside mpv, and answers the only question that matters.

+

TRACES: UR-080 | DR-233

+
Source

pub unsafe fn render(&mut self, width: i32, height: i32) -> Option<u32>

Render the current frame at width x height, returning the texture id +holding it. The GL context must be current.

+

TRACES: UR-080 | DR-231

+
Source

pub unsafe fn report_swap(&self)

Tell mpv the frame reached the screen. See [set_update_callback].

+

TRACES: UR-080 | DR-233

+
Source

unsafe fn ensure_target(&mut self, width: i32, height: i32) -> Option<()>

Create or resize the framebuffer. Reused across frames — reallocating per +frame would churn GPU memory at the display rate.

+
Source

unsafe fn drop_target(&mut self)

Source

pub unsafe fn destroy(self)

Free everything, with the GL context current.

+

Explicit rather than left to Drop because the ordering matters and the +caller is the only one that can guarantee the GL context is current. See +DR-232.

+

Trait Implementations§

Source§

impl Drop for MpvRenderContext

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 Send for MpvRenderContext

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_render/struct.Target.html b/api/jellytau_lib/player/mpv_render/struct.Target.html new file mode 100644 index 00000000..980e83a9 --- /dev/null +++ b/api/jellytau_lib/player/mpv_render/struct.Target.html @@ -0,0 +1,30 @@ +Target in jellytau_lib::player::mpv_render - RustSkip to main content

Target

Struct Target 

Source
struct Target {
+    fbo: u32,
+    texture: u32,
+    width: i32,
+    height: i32,
+}
Expand description

A colour-renderable framebuffer mpv draws into, sized to the widget.

+

Fields§

§fbo: u32§texture: u32§width: i32§height: i32

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/native_video/constant.ENV_FLAG.html b/api/jellytau_lib/player/native_video/constant.ENV_FLAG.html new file mode 100644 index 00000000..127bbf87 --- /dev/null +++ b/api/jellytau_lib/player/native_video/constant.ENV_FLAG.html @@ -0,0 +1,5 @@ +ENV_FLAG in jellytau_lib::player::native_video - RustSkip to main content

ENV_FLAG

Constant ENV_FLAG 

Source
const ENV_FLAG: &str = "JELLYTAU_NATIVE_VIDEO";
Expand description

The opt-in for native desktop video.

+

Off by default while the render path is unproven — the webview path still +works and is what ships. This becomes the default (and then the only path) +when DR-235 lands; the variable is how it is exercised until then.

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

enabled

Function enabled 

Source
pub fn enabled() -> bool
Expand description

Whether mpv should decode and draw video in this process.

+

Read fresh rather than cached: it is consulted a handful of times at startup, +and a OnceLock here would only make it harder to test.

+

TRACES: UR-080 | DR-231, DR-235

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

Module native_video

Module native_video 

Source
Expand description

Whether this process renders video natively — one answer, three consumers +(UR-080 / DR-231, DR-235). +Whether this process renders video natively, answered once.

+

Three things need this and must agree: the mpv backend (which has to be +configured for video at construction, before anything plays), the video +surface (which has nothing to draw otherwise), and get_player_status +(which tells the frontend whether to use a webview <video> element).

+

It is a function rather than three env::var checks for the reason this +codebase keeps rediscovering: a capability answered in several places is a +capability whose answers drift. Four separate bugs this cycle came from +exactly that shape — a webview’s decode limits applied to ExoPlayer, a +transcode target contradicting a direct-play claim, a codec list hardcoded in +a URL builder. One source, read by everyone.

+

TRACES: UR-080 | DR-231, DR-235

+

Constants§

ENV_FLAG 🔒
The opt-in for native desktop video.

Functions§

enabled
Whether mpv should decode and draw video in this process.
\ No newline at end of file diff --git a/api/jellytau_lib/player/native_video/sidebar-items.js b/api/jellytau_lib/player/native_video/sidebar-items.js new file mode 100644 index 00000000..cfb35ea5 --- /dev/null +++ b/api/jellytau_lib/player/native_video/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["ENV_FLAG"],"fn":["enabled"]}; \ 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..59534cce --- /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..2017e8eb --- /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..c82a1880 --- /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..26f15a20 --- /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..6731cec4 --- /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..38f9d54a --- /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..c230ff3d --- /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..39725d5d --- /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..52d9630c --- /dev/null +++ b/api/jellytau_lib/player/seek/fn.determine_video_seek_strategy.html @@ -0,0 +1,17 @@ +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,
+    seeks_transcoded_in_place: 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
  • +
  • seeks_transcoded_in_place - Whether the engine rendering this stream +can seek a server-side transcode without re-opening it. Declared by the +engine via Capabilities, never inferred from the URL or the renderer.
  • +
  • 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..a70a2db4 --- /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..321cb976 --- /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..8d7b7f74 --- /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..9de8912d --- /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..ec0fabd3 --- /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..3a1ac348 --- /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","legacy_player","media","media_player","mpv_backend","mpv_player","mpv_render","native_video","queue","seek","session","sleep_timer","state","stream_end","video_surface"],"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..382eac4e --- /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..2baa376e --- /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..3be9796e --- /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..a84220e6 --- /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..4fdc006d --- /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..928dfdbb --- /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..4c2e27dd --- /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..ef5e8262 --- /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..0080d450 --- /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..9f36e2a8 --- /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..4aa242bc --- /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..8921fdbb --- /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..c0d0badc --- /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..17d74eab --- /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..06623e1c --- /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..199d361e --- /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..db8362b5 --- /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..7d3ac282 --- /dev/null +++ b/api/jellytau_lib/player/struct.PlayerController.html @@ -0,0 +1,425 @@ +PlayerController in jellytau_lib::player - RustSkip to main content

PlayerController

Struct PlayerController 

Source
pub struct PlayerController {
Show 19 fields + backend: Arc<Mutex<Box<dyn MediaPlayer>>>, + 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 MediaPlayer>>>

The engine. One contract, so the controller stops branching on which +platform it is running on — see docs/specs/media-player-controller.md.

+
§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 MediaPlayer>, + 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

+

Not gated to Linux. Its caller stopped being a #[cfg] branch and became +a runtime question — “does this renderer draw the picture?” — so the +else arm is compiled on every platform even where it never runs. The +gate outliving its caller broke the Android build outright, which went +unnoticed because nothing built for Android afterwards.

+
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 capabilities(&self) -> Capabilities

What the engine currently rendering can do.

+

TRACES: UR-081 | DR-246

+
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..4ff7dbc2 --- /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..1f22f087 --- /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/player/video_surface/constant.GL_TEXTURE.html b/api/jellytau_lib/player/video_surface/constant.GL_TEXTURE.html new file mode 100644 index 00000000..d7fae3ce --- /dev/null +++ b/api/jellytau_lib/player/video_surface/constant.GL_TEXTURE.html @@ -0,0 +1,3 @@ +GL_TEXTURE in jellytau_lib::player::video_surface - RustSkip to main content

GL_TEXTURE

Constant GL_TEXTURE 

Source
const GL_TEXTURE: i32 = 0x1702;
Expand description

GL enum for gdk_cairo_draw_from_gl’s source_type. GDK takes the GL +constant itself rather than an enum of its own.

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/video_surface/constant.LIVE_SURFACE.html b/api/jellytau_lib/player/video_surface/constant.LIVE_SURFACE.html new file mode 100644 index 00000000..df43c0db --- /dev/null +++ b/api/jellytau_lib/player/video_surface/constant.LIVE_SURFACE.html @@ -0,0 +1,2 @@ +LIVE_SURFACE in jellytau_lib::player::video_surface - RustSkip to main content

LIVE_SURFACE

Constant LIVE_SURFACE 

Source
const LIVE_SURFACE: LocalKey<RefCell<Option<VideoSurface>>>;
Expand description

The one live surface, on the GTK main thread.

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/video_surface/fn.attach.html b/api/jellytau_lib/player/video_surface/fn.attach.html new file mode 100644 index 00000000..50cc2b82 --- /dev/null +++ b/api/jellytau_lib/player/video_surface/fn.attach.html @@ -0,0 +1,6 @@ +attach in jellytau_lib::player::video_surface - RustSkip to main content

attach

Function attach 

Source
pub fn attach(vbox: &Box, mpv: *mut mpv_handle) -> bool
Expand description

Start drawing mpv’s video underneath the webview.

+

vbox is Tauri’s default_vbox() — the container the webview already lives +in. It is not modified; only a draw handler is added.

+

Must run on the GTK main thread.

+

TRACES: UR-080 | DR-231, DR-232, DR-233

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

detach

Function detach 

Source
pub fn detach()
Expand description

Drop the live surface, if there is one. Idempotent.

+

TRACES: UR-080 | DR-232

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

draw

Function draw 

Source
fn draw(widget: &Box, cr: &Context, state: &Rc<RefCell<SurfaceState>>)
Expand description

Draw the current frame, if there is one.

+

Runs before the children, which is what puts the picture behind the +webview. Deliberately forgiving: no frame, no GL, or a borrowed state all +mean “draw nothing this pass” rather than an error — the webview then paints +over an untouched background, which is exactly the pre-native appearance.

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

init_gl

Function init_gl 

Source
fn init_gl(
+    widget: &Box,
+    state: &Rc<RefCell<SurfaceState>>,
+) -> Result<(), String>
Expand description

Create the GL context and the mpv render context over it.

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

on_mpv_update

Function on_mpv_update 

Source
unsafe extern "C" fn on_mpv_update(ctx: *mut c_void)
Expand description

mpv’s update callback. Runs on an mpv thread, so it does the least possible: +flags the state and asks GTK to redraw on the main loop.

+

Nothing here may block or re-enter the player. The project’s deadlock +gotcha applies with full force — this is called from mpv’s own threads.

+

TRACES: UR-080 | DR-233

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

Module video_surface

Module video_surface 

Source
Expand description

The native video surface mpv renders into (UR-080 / DR-231).

+

Linux-gated because the surface is GTK. Everything around it — the render +context, its lifetime, frame pacing, the device profile — is not. +The native video surface: mpv drawn behind Tauri’s webview, without +touching the widget tree.

+

§Why there is no overlay here

+

The obvious arrangement — wrap the webview in a GtkOverlay with a +GtkGLArea beneath — attaches cleanly and then aborts the process on the +first click. tauri-runtime-wry connects a button-press handler to the +webview that walks a hard-coded path:

+
webview.parent()          // "This one should be GtkBox"
+       .parent()          // ...and this one the GtkWindow
+       .downcast::<gtk::Window>().unwrap()
+

An overlay makes that chain webview → GtkOverlay → GtkBox, the downcast +fails, and because the panic is non-unwinding it takes the app with it. +Nothing in configuration avoids it: on Linux the handler is attached +unconditionally (the Windows path guards it behind is_decorated()), and +the decoration check that would make it inert runs after the unwrap.

+

So the widget tree is left exactly as Tauri built it. GTK draws a container +before its children, so rendering into the vbox’s own draw handler puts the +picture underneath the webview for free — the same z-order, no reparenting, +one less widget, and nothing a Tauri upgrade can invalidate by assuming its +own layout.

+

TRACES: UR-080 | DR-231, DR-232, DR-233, IR-033

+

Structs§

SurfaceState 🔒
Everything the draw handler needs, shared with the GTK callbacks.
VideoSurface
A live video surface. Dropping it tears the render context down.

Constants§

GL_TEXTURE 🔒
GL enum for gdk_cairo_draw_from_gl’s source_type. GDK takes the GL +constant itself rather than an enum of its own.
LIVE_SURFACE 🔒
The one live surface, on the GTK main thread.

Functions§

attach
Start drawing mpv’s video underneath the webview.
detach
Drop the live surface, if there is one. Idempotent.
draw 🔒
Draw the current frame, if there is one.
init_gl 🔒
Create the GL context and the mpv render context over it.
on_mpv_update 🔒
mpv’s update callback. Runs on an mpv thread, so it does the least possible: +flags the state and asks GTK to redraw on the main loop.
\ No newline at end of file diff --git a/api/jellytau_lib/player/video_surface/sidebar-items.js b/api/jellytau_lib/player/video_surface/sidebar-items.js new file mode 100644 index 00000000..54465eaa --- /dev/null +++ b/api/jellytau_lib/player/video_surface/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["GL_TEXTURE","LIVE_SURFACE"],"fn":["attach","detach","draw","init_gl","on_mpv_update"],"struct":["SurfaceState","VideoSurface"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/video_surface/struct.SurfaceState.html b/api/jellytau_lib/player/video_surface/struct.SurfaceState.html new file mode 100644 index 00000000..5abdda4d --- /dev/null +++ b/api/jellytau_lib/player/video_surface/struct.SurfaceState.html @@ -0,0 +1,49 @@ +SurfaceState in jellytau_lib::player::video_surface - RustSkip to main content

SurfaceState

Struct SurfaceState 

Source
struct SurfaceState {
+    gl: Option<GLContext>,
+    render: Option<MpvRenderContext>,
+    mpv: *mut mpv_handle,
+    frame_ready: Arc<AtomicBool>,
+    callback_ctx: *mut Arc<AtomicBool>,
+    logged_first_draw: bool,
+    logged_first_frame: bool,
+    logged_size: (i32, i32),
+    logged_no_gl: bool,
+    logged_no_window: bool,
+    logged_no_size: bool,
+    logged_render_fail: bool,
+}
Expand description

Everything the draw handler needs, shared with the GTK callbacks.

+

Fields§

§gl: Option<GLContext>§render: Option<MpvRenderContext>§mpv: *mut mpv_handle§frame_ready: Arc<AtomicBool>

Set by mpv’s update callback (on an mpv thread), cleared by the frame +clock (on the main thread). The whole cross-thread contract.

+
§callback_ctx: *mut Arc<AtomicBool>

The boxed clone of frame_ready handed to mpv, reclaimed on teardown. +Null when no callback is registered.

+
§logged_first_draw: bool§logged_first_frame: bool§logged_size: (i32, i32)

Last size we logged, so a size change re-reports rather than staying silent.

+
§logged_no_gl: bool§logged_no_window: bool§logged_no_size: bool§logged_render_fail: bool

Implementations§

Source§

impl SurfaceState

Source

fn teardown(&mut self)

Tear down in the order DR-232 requires, with the GL context current.

+

The update callback is unregistered before the context is freed (inside +destroy), and the GL objects go while their context is still bound. +Getting this wrong is DR-184 on Android restated — a surface outliving +its player — and is the likeliest cause of the one unexplained SIGSEGV +the spike recorded.

+

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/video_surface/struct.VideoSurface.html b/api/jellytau_lib/player/video_surface/struct.VideoSurface.html new file mode 100644 index 00000000..4f0e185b --- /dev/null +++ b/api/jellytau_lib/player/video_surface/struct.VideoSurface.html @@ -0,0 +1,30 @@ +VideoSurface in jellytau_lib::player::video_surface - RustSkip to main content

VideoSurface

Struct VideoSurface 

Source
pub struct VideoSurface {
+    state: Rc<RefCell<SurfaceState>>,
+    widget: Box,
+    handlers: Vec<SignalHandlerId>,
+}
Expand description

A live video surface. Dropping it tears the render context down.

+

Fields§

§state: Rc<RefCell<SurfaceState>>§widget: Box§handlers: Vec<SignalHandlerId>

Trait Implementations§

Source§

impl Drop for VideoSurface

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/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..3921dd59 --- /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..643df612 --- /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..8bbaa534 --- /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..e7e5ab13 --- /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..3f06511d --- /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..45950a20 --- /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..a4d78830 --- /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..dd4fa710 --- /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..4dc6f97c --- /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..f062053b --- /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..e26bb143 --- /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.renderer_can_decode_audio.html b/api/jellytau_lib/repository/device_profile/fn.renderer_can_decode_audio.html new file mode 100644 index 00000000..61e40307 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/fn.renderer_can_decode_audio.html @@ -0,0 +1,8 @@ +renderer_can_decode_audio in jellytau_lib::repository::device_profile - RustSkip to main content

renderer_can_decode_audio

Function renderer_can_decode_audio 

Source
pub fn renderer_can_decode_audio(codec: &str) -> bool
Expand description

Whether the renderer that decodes video on this platform can also decode +this audio codec.

+

On a webview platform this is the webview’s narrow list, because the element +decodes both halves. On Android it is the device’s own list: ExoPlayer plays +the audio, so judging it against the webview’s capabilities transcodes files +that would have played.

+

TRACES: UR-004, UR-080 | DR-234

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/fn.renderer_codecs.html b/api/jellytau_lib/repository/device_profile/fn.renderer_codecs.html new file mode 100644 index 00000000..435b6004 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/fn.renderer_codecs.html @@ -0,0 +1,13 @@ +renderer_codecs in jellytau_lib::repository::device_profile - RustSkip to main content

renderer_codecs

Function renderer_codecs 

Source
pub fn renderer_codecs() -> (String, String)
Expand description

What the renderer that will actually decode video on this platform can play.

+

Returns (video_codecs, audio_codecs) as Jellyfin-style comma lists.

+

This exists because the answer was previously derived in four places and +hardcoded in a fifth, each of them assuming the webview was decoding: +the device profile, the transcoding targets, the direct-play audio +narrowing, the client-side audio override, and get_video_stream_url’s +VideoCodec. On Android the decoder is ExoPlayer, so every one of those was +wrong there — the observed cost being an hevc source re-encoded to h264 +because its audio was eac3, and dts forced to transcode though the device +decodes it.

+

One source, so the copies cannot disagree again.

+

TRACES: UR-004, UR-080 | DR-234

+
\ 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..064998da --- /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..a1530199 --- /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..e376083f --- /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..6b1c0da2 --- /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..c0d8d57b --- /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..ad5b31fd --- /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..a93516b0 --- /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..9e8e7ca6 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/index.html @@ -0,0 +1,22 @@ +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”.
renderer_can_decode_audio
Whether the renderer that decodes video on this platform can also decode +this audio codec.
renderer_codecs
What the renderer that will actually decode video on this platform can play.
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..fdeda0a9 --- /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","renderer_can_decode_audio","renderer_codecs","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..40bd214f --- /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..7d024aa8 --- /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..612d73de --- /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..e1f7068e --- /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..488af009 --- /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..ef09f90b --- /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..963a9204 --- /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..31cbb239 --- /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..e9cf0e06 --- /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..1212b4fb --- /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..08a6d3ba --- /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..f4fa3c12 --- /dev/null +++ b/api/jellytau_lib/repository/hybrid/struct.HybridRepository.html @@ -0,0 +1,495 @@ +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_stream_selection( + &self, + item_id: &str, + media_source_id: Option<&str>, + audio_stream_index: Option<i32>, +) -> Result<StreamSelection, RepoError>

Decide what stream to play and describe it fully — the DR-225 contract.

+

Online-only for the same reason as get_video_stream_url: an offline +item is a file on disk, and the caller builds +[StreamSelection::local_file] for it rather than negotiating anything.

+

TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228

+
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..996c0dbc --- /dev/null +++ b/api/jellytau_lib/repository/index.html @@ -0,0 +1,6 @@ +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 stream_selection::StreamSelection;
pub use stream_selection::Transport;
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.
stream_selection
Backend-owned stream selection (UR-079 / DR-225). +What stream to play, decided in Rust and handed to a player whole.
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..6a171d11 --- /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..2a56036e --- /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..4f071f72 --- /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..6e21ac3c --- /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..5be7f94a --- /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..70bed091 --- /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..5f77d160 --- /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..d29d6a10 --- /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..a2d49268 --- /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..fbc632da --- /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..458c409f --- /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..6a835dae --- /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..98963e08 --- /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..44e214ca --- /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..33c447ac --- /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..4b511166 --- /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.clear_playback_quality_override.html b/api/jellytau_lib/repository/online/fn.clear_playback_quality_override.html new file mode 100644 index 00000000..cac941dc --- /dev/null +++ b/api/jellytau_lib/repository/online/fn.clear_playback_quality_override.html @@ -0,0 +1,6 @@ +clear_playback_quality_override in jellytau_lib::repository::online - RustSkip to main content

clear_playback_quality_override

Function clear_playback_quality_override 

Source
pub fn clear_playback_quality_override()
Expand description

Drop any per-playback override, returning to the device default.

+

Called when playback moves to a new item: a ceiling chosen for one film must +not silently govern the next one. Autoplaying the next episode is the case +that matters — nobody re-opens the picker between episodes.

+

TRACES: UR-074, UR-079 | DR-226

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

decide_playback_kind

Function decide_playback_kind 

Source
pub fn decide_playback_kind(
+    source: &NegotiatedSource,
+    audio_forces_transcode: bool,
+    audio_track_pinned: bool,
+) -> PlaybackKind
Expand description

The direct-play / direct-stream / transcode decision.

+

A free function, and pure, so every branch can be tested against +PlaybackInfo fixtures without a server standing behind it.

+

Order matters: the two client-side overrides come first, because both +describe cases where the server’s answer is right about the file and wrong +about what this app will do with it. The server judges the file against the +profile we sent; it cannot know that this renderer will be handed the audio +separately, or that the viewer has pinned a track the file does not default +to.

+

TRACES: UR-079 | DR-228 | UT-213

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

effective_streaming_quality

Function effective_streaming_quality 

Source
pub fn effective_streaming_quality() -> StreamingQuality
Expand description

The ceiling that actually applies to a stream opened now: the per-playback +override if the viewer set one, otherwise the device default.

+

This is the single resolution point. Every URL builder and the PlaybackInfo +negotiation must go through it, for the same reason they all had to agree on +the old static: a negotiation that authorises a direct play the URL builder +then constrains (or vice versa) leaks the cap.

+

TRACES: UR-074, UR-079 | DR-226

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

playback_quality_override

Function playback_quality_override 

Source
pub fn playback_quality_override() -> Option<StreamingQuality>
Expand description

The per-playback override, if one is in force.

+

TRACES: UR-074, UR-079 | DR-226

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

set_playback_quality_override

Function set_playback_quality_override 

Source
pub fn set_playback_quality_override(quality: StreamingQuality)
Expand description

Move this playback to a different ceiling without disturbing the default.

+

TRACES: UR-074, UR-079 | DR-226

+
\ 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..fc9ea7fa --- /dev/null +++ b/api/jellytau_lib/repository/online/fn.set_streaming_quality.html @@ -0,0 +1,7 @@ +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

Set the durable device default. Applies to every stream opened afterwards +that has no per-playback override.

+

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..bbe58fb2 --- /dev/null +++ b/api/jellytau_lib/repository/online/fn.streaming_quality.html @@ -0,0 +1,6 @@ +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 durable device default, ignoring any per-playback override.

+

Read this only where the setting is the subject — the Settings screen, and +persistence. Everything that opens a stream wants +effective_streaming_quality instead.

+

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..ae3a705e --- /dev/null +++ b/api/jellytau_lib/repository/online/index.html @@ -0,0 +1,11 @@ +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
DeviceProfile 🔒
DirectPlayProfile 🔒
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).
NegotiatedSource
NegotiatedStream
OnlineRepository
Online repository - fetches data from Jellyfin server via HTTP
PlaybackInfoRequest 🔒
PlaybackInfoResponse 🔒
PlaylistItemsResponse 🔒
Jellyfin playlist items response — items include PlaylistItemId
SubtitleProfile 🔒
TranscodingProfile 🔒

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§

PLAYBACK_QUALITY_OVERRIDE 🔒
The ceiling in force for the playback happening right now, when the viewer +has moved this one item off the device default.
STREAMING_QUALITY 🔒
The viewer’s durable bandwidth ceiling — the device default.
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.
clear_playback_quality_override
Drop any per-playback override, returning to the device default.
decide_playback_kind
The direct-play / direct-stream / transcode decision.
effective_streaming_quality
The ceiling that actually applies to a stream opened now: the per-playback +override if the viewer set one, otherwise the device default.
playback_quality_override
The per-playback override, if one is in force.
set_playback_quality_override
Move this playback to a different ceiling without disturbing the default.
set_streaming_quality
Set the durable device default. Applies to every stream opened afterwards +that has no per-playback override.
streaming_quality
The durable device default, ignoring any per-playback override.
\ 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..846e4009 --- /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","clear_playback_quality_override","decide_playback_kind","effective_streaming_quality","playback_quality_override","set_playback_quality_override","set_streaming_quality","streaming_quality"],"static":["PLAYBACK_QUALITY_OVERRIDE","STREAMING_QUALITY","VIDEO_PLAY_SESSION"],"struct":["CreatePlaylistResponse","DeviceProfile","DirectPlayProfile","ItemsResponse","JRayActor","JRayContext","JellyfinItem","JellyfinMediaSource","JellyfinMediaStream","JellyfinPlaylistItem","JellyfinUserData","NegotiatedSource","NegotiatedStream","OnlineRepository","PlaybackInfoRequest","PlaybackInfoResponse","PlaylistItemsResponse","SubtitleProfile","TranscodingProfile"]}; \ No newline at end of file diff --git a/api/jellytau_lib/repository/online/static.PLAYBACK_QUALITY_OVERRIDE.html b/api/jellytau_lib/repository/online/static.PLAYBACK_QUALITY_OVERRIDE.html new file mode 100644 index 00000000..0fca6d62 --- /dev/null +++ b/api/jellytau_lib/repository/online/static.PLAYBACK_QUALITY_OVERRIDE.html @@ -0,0 +1,12 @@ +PLAYBACK_QUALITY_OVERRIDE in jellytau_lib::repository::online - RustSkip to main content

PLAYBACK_QUALITY_OVERRIDE

Static PLAYBACK_QUALITY_OVERRIDE 

Source
static PLAYBACK_QUALITY_OVERRIDE: RwLock<Option<StreamingQuality>>
Expand description

The ceiling in force for the playback happening right now, when the viewer +has moved this one item off the device default.

+

This exists because a single global cannot express “this 4K remux needs a +ceiling, that podcast does not”. The in-player picker is a “this film, this +connection” control — its own doc has said so since it was written — but it +was implemented by writing the device default, so choosing 2 Mbps to get one +awkward film moving silently capped every video played afterwards, for the +rest of the process, with the Settings screen still displaying the old value.

+

Cleared when a new item starts playing, so the override cannot outlive the +playback it was chosen for. The device default is never touched by it.

+

TRACES: UR-074, UR-079 | DR-226

+
\ 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..7edccc3b --- /dev/null +++ b/api/jellytau_lib/repository/online/static.STREAMING_QUALITY.html @@ -0,0 +1,12 @@ +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 viewer’s durable bandwidth ceiling — the device default.

+

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 (Settings), and restored from the +database at startup. Not set by the in-player picker — see +PLAYBACK_QUALITY_OVERRIDE.

+

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..f6639160 --- /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..0b380944 --- /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.DeviceProfile.html b/api/jellytau_lib/repository/online/struct.DeviceProfile.html new file mode 100644 index 00000000..acb79377 --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.DeviceProfile.html @@ -0,0 +1,41 @@ +DeviceProfile in jellytau_lib::repository::online - RustSkip to main content

DeviceProfile

Struct DeviceProfile 

Source
struct DeviceProfile {
+    name: String,
+    max_streaming_bitrate: i64,
+    max_static_bitrate: i64,
+    max_audio_channels: String,
+    direct_play_profiles: Vec<DirectPlayProfile>,
+    transcoding_profiles: Vec<TranscodingProfile>,
+    subtitle_profiles: Vec<SubtitleProfile>,
+}

Fields§

§name: String§max_streaming_bitrate: i64§max_static_bitrate: i64§max_audio_channels: String

Channels the device’s audio route can actually voice. Without it +the server may direct-play a 5.1 track to a two-channel sink, +which is silence or inaudible dialogue depending on the device.

+
§direct_play_profiles: Vec<DirectPlayProfile>§transcoding_profiles: Vec<TranscodingProfile>§subtitle_profiles: Vec<SubtitleProfile>

Trait Implementations§

Source§

impl Debug for DeviceProfile

Source§

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

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

impl Serialize for DeviceProfile

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/repository/online/struct.DirectPlayProfile.html b/api/jellytau_lib/repository/online/struct.DirectPlayProfile.html new file mode 100644 index 00000000..70af16e4 --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.DirectPlayProfile.html @@ -0,0 +1,35 @@ +DirectPlayProfile in jellytau_lib::repository::online - RustSkip to main content

DirectPlayProfile

Struct DirectPlayProfile 

Source
struct DirectPlayProfile {
+    profile_type: String,
+    container: String,
+    video_codec: Option<String>,
+    audio_codec: String,
+}

Fields§

§profile_type: String§container: String§video_codec: Option<String>§audio_codec: String

Trait Implementations§

Source§

impl Debug for DirectPlayProfile

Source§

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

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

impl Serialize for DirectPlayProfile

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/repository/online/struct.ItemsResponse.html b/api/jellytau_lib/repository/online/struct.ItemsResponse.html new file mode 100644 index 00000000..1c95b3ea --- /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..a0eb42a1 --- /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..a6d00776 --- /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..85c4eb09 --- /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..5064dbc0 --- /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..b2a77423 --- /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..fca0ad02 --- /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..d5c80b7e --- /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.NegotiatedSource.html b/api/jellytau_lib/repository/online/struct.NegotiatedSource.html new file mode 100644 index 00000000..0fab1e0b --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.NegotiatedSource.html @@ -0,0 +1,51 @@ +NegotiatedSource in jellytau_lib::repository::online - RustSkip to main content

NegotiatedSource

Struct NegotiatedSource 

Source
pub struct NegotiatedSource {
+    pub id: String,
+    pub supports_direct_play: bool,
+    pub supports_direct_stream: bool,
+    pub supports_transcoding: bool,
+    pub transcoding_url: Option<String>,
+    pub bitrate: Option<i64>,
+    pub media_streams: Vec<NegotiatedStream>,
+}

Fields§

§id: String§supports_direct_play: bool§supports_direct_stream: bool

The container can be repackaged without re-encoding — a remux. Distinct +from direct play (which copies the file untouched) and from transcoding +(which spends encoder time); the distinction is what +PlaybackKind reports, and it is +the difference between “costs the server nothing” and “costs it a core”.

+
§supports_transcoding: bool§transcoding_url: Option<String>§bitrate: Option<i64>

The source’s own bitrate, when the server reports one.

+

Fills the quality picker’s “this rung is the same as Original” judgement +(DR-227). Absent for some containers — the sampled library has avi +files with no bitrate at all — in which case nothing is judged redundant +and every rung stays offered.

+
§media_streams: Vec<NegotiatedStream>

Trait Implementations§

Source§

impl Debug for NegotiatedSource

Source§

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

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

impl<'de> Deserialize<'de> for NegotiatedSource

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.NegotiatedStream.html b/api/jellytau_lib/repository/online/struct.NegotiatedStream.html new file mode 100644 index 00000000..6e6ad3be --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.NegotiatedStream.html @@ -0,0 +1,39 @@ +NegotiatedStream in jellytau_lib::repository::online - RustSkip to main content

NegotiatedStream

Struct NegotiatedStream 

Source
pub struct NegotiatedStream {
+    stream_type: String,
+    index: i32,
+    codec: Option<String>,
+    is_default: bool,
+}

Fields§

§stream_type: String§index: i32§codec: Option<String>§is_default: bool

The track the server serves when the client pins none.

+

Trait Implementations§

Source§

impl Debug for NegotiatedStream

Source§

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

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

impl<'de> Deserialize<'de> for NegotiatedStream

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.OnlineRepository.html b/api/jellytau_lib/repository/online/struct.OnlineRepository.html new file mode 100644 index 00000000..058c7b49 --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.OnlineRepository.html @@ -0,0 +1,472 @@ +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 effective_streaming_quality +ceiling (the per-playback override if one is set, else the device default): +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.

+
Source

async fn negotiate_playback( + &self, + item_id: &str, +) -> Result<(NegotiatedSource, String), RepoError>

Ask the server what it will do with this item, under our device profile +and the ceiling currently in force.

+

The single place the device profile is built and POSTed. Both +get_playback_info (the legacy shape) and +get_stream_selection (the DR-225 contract) go through it, so the +profile they negotiate under cannot drift apart.

+

TRACES: UR-004, UR-074, UR-079 | DR-225, DR-228

+
Source

pub async fn get_stream_selection( + &self, + item_id: &str, + media_source_id: Option<&str>, + audio_stream_index: Option<i32>, +) -> Result<StreamSelection, RepoError>

Decide what stream to play, and describe it fully enough that no consumer +has to guess.

+

This replaces get_video_stream_url’s “always build an HLS transcode” +with an actual decision. Measured against the development server, that +distinction is worth 85% of plays on Android (ExoPlayer decodes hevc, and +the sampled library is ~80% hevc) and about 7% on Linux, where the +WebKitGTK profile can only claim h264 until the native-video work lands. +The Linux figure is a property of the renderer, not of this code.

+

The returned StreamSelection carries the transport explicitly so the +frontend stops testing the URL for .m3u8, and the quality ladder for +this source so the picker stops offering rungs that mean nothing for it.

+

TRACES: UR-070, UR-079 | DR-225, DR-226, DR-227, DR-228 | UT-213

+

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.PlaybackInfoRequest.html b/api/jellytau_lib/repository/online/struct.PlaybackInfoRequest.html new file mode 100644 index 00000000..1a0537db --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.PlaybackInfoRequest.html @@ -0,0 +1,42 @@ +PlaybackInfoRequest in jellytau_lib::repository::online - RustSkip to main content

PlaybackInfoRequest

Struct PlaybackInfoRequest 

Source
struct PlaybackInfoRequest {
+    user_id: String,
+    audio_stream_index: Option<i32>,
+    subtitle_stream_index: Option<i32>,
+    start_time_ticks: i64,
+    is_playback: bool,
+    auto_open_live_stream: bool,
+    max_streaming_bitrate: i64,
+    device_profile: Option<DeviceProfile>,
+}

Fields§

§user_id: String§audio_stream_index: Option<i32>

Omitted so the server resolves the source’s default audio stream. +Never send 0 here: the index is global across all streams, so 0 is +the video stream and the negotiated source comes back soundless.

+
§subtitle_stream_index: Option<i32>§start_time_ticks: i64§is_playback: bool§auto_open_live_stream: bool§max_streaming_bitrate: i64§device_profile: Option<DeviceProfile>

Trait Implementations§

Source§

impl Debug for PlaybackInfoRequest

Source§

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

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

impl Serialize for PlaybackInfoRequest

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/repository/online/struct.PlaybackInfoResponse.html b/api/jellytau_lib/repository/online/struct.PlaybackInfoResponse.html new file mode 100644 index 00000000..52fe20ba --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.PlaybackInfoResponse.html @@ -0,0 +1,36 @@ +PlaybackInfoResponse in jellytau_lib::repository::online - RustSkip to main content

PlaybackInfoResponse

Struct PlaybackInfoResponse 

Source
struct PlaybackInfoResponse {
+    media_sources: Vec<NegotiatedSource>,
+    play_session_id: String,
+}

Fields§

§media_sources: Vec<NegotiatedSource>§play_session_id: String

Trait Implementations§

Source§

impl Debug for PlaybackInfoResponse

Source§

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

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

impl<'de> Deserialize<'de> for PlaybackInfoResponse

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.PlaylistItemsResponse.html b/api/jellytau_lib/repository/online/struct.PlaylistItemsResponse.html new file mode 100644 index 00000000..ba2d4973 --- /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/online/struct.SubtitleProfile.html b/api/jellytau_lib/repository/online/struct.SubtitleProfile.html new file mode 100644 index 00000000..19456458 --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.SubtitleProfile.html @@ -0,0 +1,33 @@ +SubtitleProfile in jellytau_lib::repository::online - RustSkip to main content

SubtitleProfile

Struct SubtitleProfile 

Source
struct SubtitleProfile {
+    format: String,
+    method: String,
+}

Fields§

§format: String§method: String

Trait Implementations§

Source§

impl Debug for SubtitleProfile

Source§

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

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

impl Serialize for SubtitleProfile

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/repository/online/struct.TranscodingProfile.html b/api/jellytau_lib/repository/online/struct.TranscodingProfile.html new file mode 100644 index 00000000..cc635b0e --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.TranscodingProfile.html @@ -0,0 +1,38 @@ +TranscodingProfile in jellytau_lib::repository::online - RustSkip to main content

TranscodingProfile

Struct TranscodingProfile 

Source
struct TranscodingProfile {
+    profile_type: String,
+    context: String,
+    protocol: String,
+    container: String,
+    video_codec: Option<String>,
+    audio_codec: String,
+    max_audio_channels: String,
+}

Fields§

§profile_type: String§context: String§protocol: String§container: String§video_codec: Option<String>§audio_codec: String§max_audio_channels: String

Trait Implementations§

Source§

impl Debug for TranscodingProfile

Source§

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

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

impl Serialize for TranscodingProfile

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/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..682c6149 --- /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..4cadb5fd --- /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..0fb43486 --- /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..cd98389c --- /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..fbc6fe1b --- /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..513922d8 --- /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..c0d05a06 --- /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..229d47b0 --- /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..6c024df3 --- /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..60ee587b --- /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..a5360e01 --- /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..b740bb4e --- /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..1767ccce --- /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..c309cf3d --- /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..52833e54 --- /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..06925a42 --- /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","stream_selection","types"],"trait":["MediaRepository"]}; \ No newline at end of file diff --git a/api/jellytau_lib/repository/stream_selection/enum.PlaybackKind.html b/api/jellytau_lib/repository/stream_selection/enum.PlaybackKind.html new file mode 100644 index 00000000..6e731fa8 --- /dev/null +++ b/api/jellytau_lib/repository/stream_selection/enum.PlaybackKind.html @@ -0,0 +1,77 @@ +PlaybackKind in jellytau_lib::repository::stream_selection - RustSkip to main content

PlaybackKind

Enum PlaybackKind 

Source
pub enum PlaybackKind {
+    DirectPlay,
+    DirectStream,
+    Transcode,
+}
Expand description

What the server is doing to the source to produce this stream.

+

Distinct from Transport because the two are genuinely independent: a +direct-streamed remux and a transcode can both arrive over HLS, and a direct +play can arrive progressively or as a local file. Keeping them apart is what +lets the UI say “this is not costing the server anything” without inferring +it from a URL shape.

+

TRACES: UR-079 | DR-228

+

Variants§

§

DirectPlay

The source file is served untouched. No server CPU, no quality loss.

+
§

DirectStream

The container is repackaged but the codecs are copied — cheap, and +visually identical to the source.

+
§

Transcode

The server is re-encoding. The only case where a bitrate ceiling can +actually be honoured, and the only one that costs the server real work.

+

Implementations§

Source§

impl PlaybackKind

Source

pub fn needs_transcoding(&self) -> bool

Whether the server is spending encoder time on this stream.

+

The queue carries a needs_transcoding flag that predates this enum and +that several seek/reload paths still branch on; this keeps the two from +drifting by making one derive from the other.

+

TRACES: UR-079 | DR-228

+

Trait Implementations§

Source§

impl Clone for PlaybackKind

Source§

fn clone(&self) -> PlaybackKind

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 PlaybackKind

Source§

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

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

impl<'de> Deserialize<'de> for PlaybackKind

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 PlaybackKind

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 PlaybackKind

Source§

fn eq(&self, other: &PlaybackKind) -> 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 PlaybackKind

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 PlaybackKind

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 PlaybackKind

Source§

impl Eq for PlaybackKind

Source§

impl Flatten for PlaybackKind

Source§

impl StructuralPartialEq for PlaybackKind

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/stream_selection/enum.Transport.html b/api/jellytau_lib/repository/stream_selection/enum.Transport.html new file mode 100644 index 00000000..7944a1f7 --- /dev/null +++ b/api/jellytau_lib/repository/stream_selection/enum.Transport.html @@ -0,0 +1,75 @@ +Transport in jellytau_lib::repository::stream_selection - RustSkip to main content

Transport

Enum Transport 

Source
pub enum Transport {
+    Hls,
+    Progressive,
+    LocalFile,
+}
Expand description

How the bytes of a chosen stream are fetched.

+

This field exists to delete a substring search. The frontend previously +decided which loader to attach by testing url.contains(".m3u8"), which is a +domain fact reconstructed in the presentation layer — the same class of leak +as the item-type taxonomy that check:boundary guards, and one that breaks +silently the moment a server serves a playlist from a path that does not end +in .m3u8, or serves a progressive file from one that does.

+

Tagged ({"type":"hls"}) rather than a bare string so the frontend matches a +discriminant instead of comparing text.

+

TRACES: UR-079 | DR-225

+

Variants§

§

Hls

An HLS playlist. The webview attaches hls.js (or Safari’s native loader); +ExoPlayer uses its HLS media source.

+
§

Progressive

A single progressive HTTP resource, seekable by byte range.

+
§

LocalFile

A file already on disk — a completed download, or the loopback media +server standing in front of one.

+

Trait Implementations§

Source§

impl Clone for Transport

Source§

fn clone(&self) -> Transport

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 Transport

Source§

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

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

impl<'de> Deserialize<'de> for Transport

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 Transport

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 Transport

Source§

fn eq(&self, other: &Transport) -> 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 Transport

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 Transport

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 Transport

Source§

impl Eq for Transport

Source§

impl Flatten for Transport

Source§

impl StructuralPartialEq for Transport

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/stream_selection/fn.quality_options_for_source.html b/api/jellytau_lib/repository/stream_selection/fn.quality_options_for_source.html new file mode 100644 index 00000000..b82add50 --- /dev/null +++ b/api/jellytau_lib/repository/stream_selection/fn.quality_options_for_source.html @@ -0,0 +1,14 @@ +quality_options_for_source in jellytau_lib::repository::stream_selection - RustSkip to main content

quality_options_for_source

Function quality_options_for_source 

Source
pub fn quality_options_for_source(
+    source_bitrate: Option<u64>,
+) -> Vec<QualityOption>
Expand description

Build the quality ladder as it applies to a source of a known bitrate.

+

Every rung is returned — the picker stays a fixed, predictable list rather +than one that changes length per item — but each is marked with whether it +would actually constrain this source. A rung whose ceiling is at or above +the source bitrate produces the same bytes as Original, so presenting it as +a distinct choice is noise.

+

source_bitrate is None when the server did not report one (it is absent +for some containers — the sampled library has avi files with no bitrate at +all). In that case nothing can be judged redundant and every rung is offered, +which is the safe direction: the viewer keeps every choice they had before.

+

TRACES: UR-070, UR-079 | DR-227, DR-121 | UT-212

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

Module stream_selection

Module stream_selection 

Source
Expand description

Backend-owned stream selection (UR-079 / DR-225). +What stream to play, decided in Rust and handed to a player whole.

+

Every player backend — mpv, ExoPlayer, the webview <video>/hls.js path — +used to receive a bare URL and re-derive the rest: the frontend decided +“is this HLS?” by looking for .m3u8 in the string, and nothing anywhere +carried why a stream was transcoded or what else the source could have +offered. This module is the replacement contract: one self-describing +StreamSelection that says what the stream is, how to fetch it, and what +the alternatives were.

+

The division of labour it encodes — Rust decides what stream, the player +decides how to deliver it — is the point. A multi-variant playlist handed +to ExoPlayer is still ExoPlayer’s to adapt over; Rust never paces bytes.

+

TRACES: UR-079 | DR-225

+

Structs§

QualityOption
One rung of the quality picker, as it applies to this media source.
Rendition
The rendition actually negotiated — what the viewer is receiving right now.
StreamSelection
Everything a player backend needs to open a stream, and everything the UI +needs to describe it.

Enums§

PlaybackKind
What the server is doing to the source to produce this stream.
Transport
How the bytes of a chosen stream are fetched.

Functions§

quality_options_for_source
Build the quality ladder as it applies to a source of a known bitrate.
\ No newline at end of file diff --git a/api/jellytau_lib/repository/stream_selection/sidebar-items.js b/api/jellytau_lib/repository/stream_selection/sidebar-items.js new file mode 100644 index 00000000..aaeadb8e --- /dev/null +++ b/api/jellytau_lib/repository/stream_selection/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["PlaybackKind","Transport"],"fn":["quality_options_for_source"],"struct":["QualityOption","Rendition","StreamSelection"]}; \ No newline at end of file diff --git a/api/jellytau_lib/repository/stream_selection/struct.QualityOption.html b/api/jellytau_lib/repository/stream_selection/struct.QualityOption.html new file mode 100644 index 00000000..afa3deb4 --- /dev/null +++ b/api/jellytau_lib/repository/stream_selection/struct.QualityOption.html @@ -0,0 +1,76 @@ +QualityOption in jellytau_lib::repository::stream_selection - RustSkip to main content

QualityOption

Struct QualityOption 

Source
pub struct QualityOption {
+    pub quality: StreamingQuality,
+    pub label: String,
+    pub detail: String,
+    pub exceeds_source: bool,
+    pub source_bitrate: Option<u64>,
+}
Expand description

One rung of the quality picker, as it applies to this media source.

+

The picker used to be filled from the fixed StreamingQuality::ALL ladder, +which meant offering “20 Mbps” for a 1.1 Mbps podcast — eight rungs, six of +them indistinguishable from Original. exceeds_source is what lets the +frontend render that honestly without knowing anything about bitrates.

+

TRACES: UR-070, UR-079 | DR-227, DR-121

+

Fields§

§quality: StreamingQuality§label: String

Human label (“8 Mbps”). Lives in Rust beside the number it describes.

+
§detail: String

Secondary line (“1080p”).

+
§exceeds_source: bool

True when this rung’s ceiling is at or above what the source itself +carries, so selecting it yields the same stream as Original.

+

The frontend renders these differently (or hides them); it does not +decide which they are.

+
§source_bitrate: Option<u64>

The source’s own bitrate, when the server reported one. Presentation +only — the picker shows “Original (6.7 Mbps)” rather than a bare word.

+

Trait Implementations§

Source§

impl Clone for QualityOption

Source§

fn clone(&self) -> QualityOption

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 QualityOption

Source§

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

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

impl<'de> Deserialize<'de> for QualityOption

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 QualityOption

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 QualityOption

Source§

fn eq(&self, other: &QualityOption) -> 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 QualityOption

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 QualityOption

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 QualityOption

Source§

impl Flatten for QualityOption

Source§

impl StructuralPartialEq for QualityOption

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/stream_selection/struct.Rendition.html b/api/jellytau_lib/repository/stream_selection/struct.Rendition.html new file mode 100644 index 00000000..ad81f334 --- /dev/null +++ b/api/jellytau_lib/repository/stream_selection/struct.Rendition.html @@ -0,0 +1,72 @@ +Rendition in jellytau_lib::repository::stream_selection - RustSkip to main content

Rendition

Struct Rendition 

Source
pub struct Rendition {
+    pub quality: StreamingQuality,
+    pub max_bitrate: Option<u64>,
+    pub max_height: Option<u32>,
+    pub video_codec: Option<String>,
+    pub audio_codec: Option<String>,
+}
Expand description

The rendition actually negotiated — what the viewer is receiving right now.

+

None on a StreamSelection when the source is being direct-played as-is: +there is no chosen rendition in that case, only the file itself, and +reporting the ceiling that happened to be set would misdescribe it.

+

TRACES: UR-079 | DR-225, DR-226

+

Fields§

§quality: StreamingQuality

The rung of the ladder this stream was built against.

+
§max_bitrate: Option<u64>

Total bits per second the stream may use, when a ceiling applies.

+
§max_height: Option<u32>

Resolution ceiling, when one applies. None preserves the source’s.

+
§video_codec: Option<String>

Video codec the server was asked to produce.

+
§audio_codec: Option<String>

Audio codec the server was asked to produce.

+

Trait Implementations§

Source§

impl Clone for Rendition

Source§

fn clone(&self) -> Rendition

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 Rendition

Source§

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

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

impl<'de> Deserialize<'de> for Rendition

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 Rendition

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 Rendition

Source§

fn eq(&self, other: &Rendition) -> 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 Rendition

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 Rendition

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 Rendition

Source§

impl Flatten for Rendition

Source§

impl StructuralPartialEq for Rendition

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/stream_selection/struct.StreamSelection.html b/api/jellytau_lib/repository/stream_selection/struct.StreamSelection.html new file mode 100644 index 00000000..acc3a367 --- /dev/null +++ b/api/jellytau_lib/repository/stream_selection/struct.StreamSelection.html @@ -0,0 +1,103 @@ +StreamSelection in jellytau_lib::repository::stream_selection - RustSkip to main content

StreamSelection

Struct StreamSelection 

Source
pub struct StreamSelection {
+    pub url: String,
+    pub transport: Transport,
+    pub playback_kind: PlaybackKind,
+    pub rendition: Option<Rendition>,
+    pub available: Vec<QualityOption>,
+    pub media_source_id: Option<String>,
+    pub play_session_id: Option<String>,
+    pub needs_transcoding: bool,
+}
Expand description

Everything a player backend needs to open a stream, and everything the UI +needs to describe it.

+

Replaces the bare String URL that get_video_stream_url used to return.

+

TRACES: UR-079 | DR-225, DR-227, DR-228

+

Fields§

§url: String

The URL (or loopback URL) to open.

+
§transport: Transport

How to fetch it. Replaces the .m3u8 substring check.

+
§playback_kind: PlaybackKind

What the server is doing to the source to produce it.

+
§rendition: Option<Rendition>

The negotiated rendition; None when direct-playing the source as-is.

+
§available: Vec<QualityOption>

What this media source can offer, for the quality picker (DR-227).

+
§media_source_id: Option<String>

The media source this selection is for, so a later re-open (quality +change, audio-track switch, transcoded seek) targets the same one.

+
§play_session_id: Option<String>

The transcode identity the server keyed this job by, when there is one.

+
§needs_transcoding: bool

Whether the server is spending encoder time on this stream.

+

Derived from playback_kind rather than left for +the frontend to compute: “which kinds count as transcoding” is a domain +rule, and a direct stream is a remux that must not be counted. The +queue’s long-standing needs_transcoding flag and the seek strategy both +read this, so there is one answer rather than three.

+

TRACES: UR-079 | DR-225, DR-228

+

Implementations§

Source§

impl StreamSelection

Source

pub fn local_file(url: impl Into<String>) -> Self

A selection for a file already on disk.

+

A downloaded file is a direct play by definition — the bytes are the +source’s — and offering a quality ladder over it would be a lie, since +nothing about a local file can be re-negotiated.

+

TRACES: UR-071, UR-079 | DR-225

+
Source

pub fn for_queued_item( + url: impl Into<String>, + transport: Option<Transport>, + needs_transcoding: bool, +) -> Self

A selection for an item already sitting in the queue.

+

The queue predates StreamSelection: its items carry a URL, an optional +transport and the older needs_transcoding flag. This rebuilds a +selection from those without re-negotiating with the server, so the +controller can hand an engine an OpenRequest for an item it already +holds.

+

The transport falls back rather than being sniffed from the URL — the +substring check is exactly what DR-230 removed. needs_transcoding is an +exact stand-in because every transcode this app requests is HLS (DR-140).

+

TRACES: UR-079, UR-081 | DR-225, DR-245

+

Trait Implementations§

Source§

impl Clone for StreamSelection

Source§

fn clone(&self) -> StreamSelection

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 StreamSelection

Source§

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

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

impl<'de> Deserialize<'de> for StreamSelection

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 StreamSelection

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 StreamSelection

Source§

fn eq(&self, other: &StreamSelection) -> 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 StreamSelection

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 StreamSelection

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 StreamSelection

Source§

impl Flatten for StreamSelection

Source§

impl StructuralPartialEq for StreamSelection

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/trait.MediaRepository.html b/api/jellytau_lib/repository/trait.MediaRepository.html new file mode 100644 index 00000000..ba59076e --- /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..616e8806 --- /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..f6f2bc2f --- /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..b66cff56 --- /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..ba9449af --- /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..05ce631b --- /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..e3cfb7f5 --- /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..c104b3f0 --- /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..b4c752ee --- /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..b3d50a81 --- /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..3bb7a0ec --- /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..dbaa82d0 --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.LiveStreamInfo.html @@ -0,0 +1,65 @@ +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>,
+    pub transport: Transport,
+}
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>§transport: Transport

How to open stream_url.

+

A live channel is always an HLS transcode — the server has to repackage a +broadcast mux into something a browser can play, and there is no static +file to direct-play. Saying so here means the player page never has to +work it out from the URL, which is the whole of DR-225.

+

TRACES: UR-079 | DR-225

+

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..5a8ecb1a --- /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..9b2682be --- /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..5af9b114 --- /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..539876aa --- /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..4040b877 --- /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..adb2dbe1 --- /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..cca8ce0f --- /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..3d351d4b --- /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..699f2bef --- /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..f864093d --- /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..2343ff77 --- /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..22ac645a --- /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..de2504dd --- /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..39e805f5 --- /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..b462df0a --- /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..6ac92e16 --- /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..a49a1b2b --- /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..5d5f8e28 --- /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..970aebe7 --- /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..671a9ab6 --- /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..b3191dd6 --- /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..a5938d50 --- /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..23863a1b --- /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..cf31717b --- /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..e3a6a638 --- /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..0001e429 --- /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..c7d3b673 --- /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","engine_capabilities","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..06075faa --- /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..4577ac4d --- /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..5326cb7e --- /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..bb3314df --- /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..a8375b91 --- /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..5fe7bf62 --- /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..ae1e87bf --- /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..31b87652 --- /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..73112116 --- /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..5505fb0e --- /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..6e6a337c --- /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..292292be --- /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..ae8132d5 --- /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..8c3a6bf2 --- /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..67cd569e --- /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..a6328a47 --- /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..008e1e78 --- /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..c8b06971 --- /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..cb92f83e --- /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..8ffcdc4b --- /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..3972fe81 --- /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..a784d57d --- /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..a2bd5c69 --- /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..280ce1e9 --- /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..b3519a73 --- /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..2bf4f7a5 --- /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..572559d6 --- /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..6927371b --- /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..67ad8073 --- /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..7f4d9f9d --- /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..1a2f0ec6 --- /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..15f9a6de --- /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..e8dc65f7 --- /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..7b315727 --- /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..8045bd29 --- /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..2d2ba219 --- /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..0eedc73d --- /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..c22cc283 --- /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..480b7de6 --- /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..8cfd8b5c --- /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..ffc40439 --- /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..4abf6d78 --- /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..7616b8f4 --- /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..e11f1d4c --- /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..f29b2cbe --- /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..cb6ab84d --- /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..21ad97d1 --- /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..6eb79535 --- /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..a035efb7 --- /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..e200f7f6 --- /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..5fab36f0 --- /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..0043ea53 --- /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..cb3ca2fe --- /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..edd6d834 --- /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..cced4e33 --- /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..531046d6 --- /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..9d376f69 --- /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..db32821c --- /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..263b105f --- /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..7f6afe1b --- /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..ac87d123 --- /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..37fc0340 --- /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..7f5bd3ef --- /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..12a1ebad --- /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..27b5614c --- /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..6653e846 --- /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..7ec85ba8 --- /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..17ae7c32 --- /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..ba9923b7 --- /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..663fcfe7 --- /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..4f27c8b8 --- /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..6dd6d9cd --- /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..3ffa8e5f --- /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..ebdcb405 --- /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..f9fbdc4c --- /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..0f14c67c --- /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..5a2264d9 --- /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/0056ce3d79.js b/api/search.index/0056ce3d79.js new file mode 100644 index 00000000..cfe92f7e --- /dev/null +++ b/api/search.index/0056ce3d79.js @@ -0,0 +1 @@ +rn_("BQLBAAAUiRsUihtvdBOBAsYAAB1HZHAVAcMAABb7HnffGSHDAACvG3YeJQHDAAATVB5u0x7oHvMAwAiIgiEVAAAFAsAAABBcEBPeGXFyBQLAAAARTRYYSR1tb28I/RbBA8UAAB4iwQAAGgrBAAASIMIAABiQxAAAG3/7Amx0") \ No newline at end of file diff --git a/api/search.index/005e3cb133.js b/api/search.index/005e3cb133.js new file mode 100644 index 00000000..75d93fc3 --- /dev/null +++ b/api/search.index/005e3cb133.js @@ -0,0 +1 @@ +rn_("BQLAAABE7BztHO4coB8RkhhjZRMA/RAGUMgAAB/TwQAAHlzFAAAeZ8YAACCP77ANAACwGAAA6BgAAIMZAADoGQAAehsAAKIcAABnHQAAhx4AAOweAACEHwAAmB8AALkfAAC+HwAAdiAAAHggAAAwCAEVEAAgAAEAAcEAAB65a/iYGwAA+wBuAAEA7QHEABIAowAjhQLBAAAgiml5FQHGAAAQqx5zIB0xxgAAXBldGUMcBQLAAAATdR1CdRl2GVEcUhxudDHDAAD+FGIcxx8VAcUAABBAH3PsGSsCxAAAG5tsdhuZxwAAHJ/HAAAcgiHEAABcGy4focYAANQeBQLAAAAQgBYXLx90dgEA/sBrcQVWxxH1nQ+Gon2AK8b8jG7KAAAghSkLdJj8JmC2NjY6Wc/Dlh9y4SEyxgAAHoYXk9wkIyKoHbe8fHxZIVbBAAAbbzowAAABAAAAAABYABAAAACfBjYJNwklCyYLKAspCzALMgszCzQLOgs7C8oLywsQDSYNORA6EGcQRhFHEe4R7xHwEfER8hFeFF8UYBRhFGIU5BQKFTMWaxiaGBgZGhlWGVcZWBlZGVoZXBldGbsavBpCG0MbRBtFG2MbaxupG6obqxusGzIcQRxDHGMcZRxsHMkcyhzLHMwczRzOHEodSx1MHU0dcx2mHY0epR6mHqceqB6pHr8eUR9zIH8ggyCIILMg") \ No newline at end of file diff --git a/api/search.index/007c181841.js b/api/search.index/007c181841.js new file mode 100644 index 00000000..2d93cd88 --- /dev/null +++ b/api/search.index/007c181841.js @@ -0,0 +1 @@ +rn_("UcAAAGoZaxlUHVUdVh0VkMAAABYHHcMf8wQCZHIBuQ0AACHCAABcHR8eJQHCAAAwOh47HooecwMSuBehxAAAZR8lAsMAABYGHRZpH3Jz6xFxH/sDaHB1FQHAAAAVVBlocR0bAskAAB2UZHWfAf0hEAbDAAAc6MwAAB8ia4vBAAAWYw==") \ No newline at end of file diff --git a/api/search.index/01f0f15011.js b/api/search.index/01f0f15011.js new file mode 100644 index 00000000..303594b3 --- /dev/null +++ b/api/search.index/01f0f15011.js @@ -0,0 +1 @@ +rn_("IcMAALsXUSAFAsAAABlfHxZMHmF3BQLDAAAUoR4Wih5pclsDxQAAGrdhbXQBggLQAAAgtcEAAB5LaXYVAcMAABWdH3D/HJOC/QCJCsUAAB6gxAAAHkrCAAAdAPsDYWV0") \ No newline at end of file diff --git a/api/search.index/024bba900a.js b/api/search.index/024bba900a.js new file mode 100644 index 00000000..2cbd41bc --- /dev/null +++ b/api/search.index/024bba900a.js @@ -0,0 +1 @@ +rn_("JQHAAAAYpxxzLhx+HwFHOzAAAAEAAIoAAgCzGAAADxqJAAFDOzAAAAEAAIkAAQA8E4kA8wACaXQ7MAAAAQAAigACAMkHAAAJCIkAOwPCAAAgcWVncw==") \ No newline at end of file diff --git a/api/search.index/03358430c1.js b/api/search.index/03358430c1.js new file mode 100644 index 00000000..3a782d09 --- /dev/null +++ b/api/search.index/03358430c1.js @@ -0,0 +1 @@ +rn_("UcsAAJ4dnx2gHaEdiyAhxAAAGxX4HhcIAP4AQeAVwgAAEzvVAAAgksMAABUcygAAHZ3MAAAew8IAABtsgwYIAAArAsoAAB8tbnI=") \ No newline at end of file diff --git a/api/search.index/036ba5ed65.js b/api/search.index/036ba5ed65.js new file mode 100644 index 00000000..06bb2572 --- /dev/null +++ b/api/search.index/036ba5ed65.js @@ -0,0 +1 @@ +rn_("McEAACoNUxbXHgUCwAAAFy0cEz0dYXI1gsEAALSLHKUXuhx2HyHAAABXHlAgZQHAAAAWHR13+xT8FP0U+xn8Gfsc+wNlc3UrAskAAB7DbHSfAf0tQAHDAAAfo8IAABzIwgAAHtyhwwAAThYjAALAAAAQwWl06B4JAABQFAAAixcAAH4bAAByHQAAJR4AAEoeAADnHgAAjCAAABMAAwAAGwLCAAAgV251FQHCAAARrxl0KQ0FAsEAAB6EHx+zH2xzZQHBAADAHRVz8QzyDPMM0xTUFJMdawTAAAAXtMAAABDJbm9zdgWMwAAAG74fFNscBQPAAAAYIR8bvB8bvR9sdHUFAsIAABe7HxQgH3N0E4ECzAAAIHhkdNsEyQAAH1JhY3N2FQHBAAAU2hx2UhahwwAAmRs/Af4ABUICwwAAFkzGAAAbR+cDAP0RRVTAAAAfxM0AAB+/wQAAGb7/PQkAAM4BAQBXAhYMRwD1AAEAegHOAEsAXACiAAgAJQI=") \ No newline at end of file diff --git a/api/search.index/0433b1c82d.js b/api/search.index/0433b1c82d.js new file mode 100644 index 00000000..60029a55 --- /dev/null +++ b/api/search.index/0433b1c82d.js @@ -0,0 +1 @@ +rn_("AQSBwQAAHv7t4hkAAJgcAAAMHQAAFR0AAB0dAACKHQAAYh4AAGceAAC+HgAA+x4AAAEfAAA3HwAAah8AANAfAAAAAAAAAQAS") \ No newline at end of file diff --git a/api/search.index/043ccdfc1c.js b/api/search.index/043ccdfc1c.js new file mode 100644 index 00000000..f3ebfa6d --- /dev/null +++ b/api/search.index/043ccdfc1c.js @@ -0,0 +1 @@ +rn_("BQLAAAATCxoTDBpscHHAAACLC4wLjQtbFLIcAx8jHxcEAf4AhoYNyQAAH43GAAAfTNgAACCzyAAAHo/LAAAeyMEAABiUywAAIHTuUQoAAIgLAACnDQAAbRAAAAgSAABbFgAAdhgAAE4ZAACnGQAATRwAACseAACMHgAALx8AAGYgAAB+IAAAERAAMAAAAAA=") \ No newline at end of file diff --git a/api/search.index/04517aebfb.js b/api/search.index/04517aebfb.js new file mode 100644 index 00000000..2c14281f --- /dev/null +++ b/api/search.index/04517aebfb.js @@ -0,0 +1 @@ +rn_("BQLAAAAXEB0S6RdpcjHAAAA6Hjseih4Fg8AAABOpGBquHgWFwAAAF7EeF7IeIwQCwAAAGL0ybQEREgAABQLCAAASsRwQGh9peSsC4AAAHnlkbvMAguLlFAAADRoAALEeAAAAEGvEADDEAAAbsNcBAP4ABIQLyAAAHYTFAAAesDswAAABAAA2ABAAKAkCAPwKAAAeCwAAYgsjAP4MAAADEgAAWBQCALgXAADfGAAAZxkAAGwZAAB/GwAAeRwAAB4dAAAlHQAAtx4AAA==") \ No newline at end of file diff --git a/api/search.index/0525706db7.js b/api/search.index/0525706db7.js new file mode 100644 index 00000000..bbe4414f --- /dev/null +++ b/api/search.index/0525706db7.js @@ -0,0 +1 @@ +rn_("BQLAAAATiRcSgBtpbiUBwQAAERAdbeoLJx0xxQAAvhdfGWAZMccAAIQehR6aIDUBxQAAFmced+gYuh++HwUCwQAAp5scR50cnhyAIIEgcnP7AmV0+wNldHUFAsAAACJQGRIfFVgeZGVxwAAA5ArlCuYK5wpKGV4fnR8FA8EAABhIHxGaHzYhHXAfch9iZXP7gxsCwwAAF5NsdCHCAAD1HIEfBQLGAAAgQh+hIGIVHxYfFx8YH1ogpCBzdSuKxQAAILPRxAAAhBcFA8AAABW9FxVeGRMCFWFpagEA/a+xG8kAAB18GUmcqT4vzV/IdRD889GQwwAAGUTEAAAYq8cAABucwwAAGaEis/jo9nH8XQ4mNXYDuK0GkmdzNwi47Ng5PXSj+HTp+QcAAPgIAACIGAAAlBkAANocAAApHgAA9x4AAKkgAAC2IAAAuCAAABAQAQQA") \ No newline at end of file diff --git a/api/search.index/055f022011.js b/api/search.index/055f022011.js new file mode 100644 index 00000000..705224df --- /dev/null +++ b/api/search.index/055f022011.js @@ -0,0 +1 @@ +rn_("NQHEAAASXB5r6BmEH5gfI4ICxgAAHohzdLHBAABqFAGCAtEAACCcxgAAHodlc1cBAP3AIQLHAAAdE8IAABiA9icLAACNAAEAqAhUBO8FocUAAEkcEwICxQAAH1tmdOQTGQAA9R4AAH8fAACVHwAAriAAADAQAPOCAml5IwACwAAAG5tscgF+GAAA") \ No newline at end of file diff --git a/api/search.index/05baa46f11.js b/api/search.index/05baa46f11.js new file mode 100644 index 00000000..199dba5f --- /dev/null +++ b/api/search.index/05baa46f11.js @@ -0,0 +1 @@ +rn_("AULirhcAAH8cAACAHQAAUAAbhsEAAB6NIcIAALoYDR9bA8gAACBRYW9yBQPAAAATqhoWZx8TqxphbXIViMUAAB+yIMseKwLAAAAQyGZsNwIA/RFBDcYAACCCwwAAGqzGAAAcfsMAAB8P+3YQAACSCJgCzQIEAFIASwA0ABgAIwEkAA==") \ No newline at end of file diff --git a/api/search.index/06321b4b68.js b/api/search.index/06321b4b68.js new file mode 100644 index 00000000..1f0b4f69 --- /dev/null +++ b/api/search.index/06321b4b68.js @@ -0,0 +1 @@ +rn_("UcIAAA4ZDxkQGREZlB8hwgAATRBXFAFA40sQAABVFAAAlh0AAFMgAAARIvMBAXPiihAAANwcAAA5HwAAGwD7AmF5scEAAGgNjxj+AlKQB8AAABiozwAAH2fEAAAZGcUAABeTxQAAGeXHAAAatxsCxAAAHrFlZg==") \ No newline at end of file diff --git a/api/search.index/0653092b69.js b/api/search.index/0653092b69.js new file mode 100644 index 00000000..30f866d6 --- /dev/null +++ b/api/search.index/0653092b69.js @@ -0,0 +1 @@ +rn_("AUTjthQAANscAABmHQAAwB8AAGAABQLAAAAmuh++HzeEHoUemiBldFHKAAAVHxYfFx8YH6Qg84MCZHNRxwAAeh57HnwefR6XIPsDY2lv") \ No newline at end of file diff --git a/api/search.index/066748aa61.js b/api/search.index/066748aa61.js new file mode 100644 index 00000000..361fbb01 --- /dev/null +++ b/api/search.index/066748aa61.js @@ -0,0 +1 @@ +rn_("BQLAAAARGRkT/hxycwkC0AAAILDCAAAeR2l2KwLKAAAgb2NyG4bBAAAcagUCwAAAHY8gE3IdbnIrA8EAABxpzQAAIIdhZWwVAsMAABVeHxD9HHBzaBwFAsMAABZdIBNdH3N1I4ECxwAAH5xjdL8D/QGJCscAAB71") \ No newline at end of file diff --git a/api/search.index/06fd3e89ae.js b/api/search.index/06fd3e89ae.js new file mode 100644 index 00000000..10a011ad --- /dev/null +++ b/api/search.index/06fd3e89ae.js @@ -0,0 +1 @@ +rn_("FQHDAAAW+x533xkhwwAArxt2HiUBwwAAE1QebtMe6B7zAMAIiIIhFQAAQ4QDwwAAGd7EAAAbf2VycxsCyQAAHR9udg==") \ No newline at end of file diff --git a/api/search.index/073f6dc227.js b/api/search.index/073f6dc227.js new file mode 100644 index 00000000..fe7579aa --- /dev/null +++ b/api/search.index/073f6dc227.js @@ -0,0 +1 @@ +rn_("ocUAAAEaJYHCAAARLRwAFSUfBQLCAAASsRwQGh9peSsC4AAAHnlkbvMCguLlFAAADRoAALEeAAAAEEWDwAAAEtoZFlEe9gj3CM0UzhQBQuSMFwAA1xcAACAeAABdHgAATh8AAAQAABOBAuAAAB0taXkFAsIAABWqHhRQHmNzocMAAJYYVQLAAACigBAUEBJpebMQAx0EHW8eqx7RwwAAexA1AcMAABChG3PzHPQcVB4BAAHDAAAX7GPmAwkAAFYKAAA/FgAASRwAAG0cAACJHgAAeh8AACABAADnAwDABszDAAAUSjowAAABAAAAAAAPABAAAABQEBYSbRSdGLYYuxgNGY4ZjxmQGQAasRt8HCAd0x7oHgmJ0AAAIGXGAAAbpQUCwAAAH3MfHAsfYXQFAsAAABf5HiAbC9AZbHIJAtYAACC2xgAAHKhkdCsCxQAAGg1mdX8B/XFoAM4AAB90zAAAHwzKAAAfAqUCwwAAFJIdFr4ec3eAGRsCxwAAHvhhbFHFAAC/HMAcwRzCHHsgBQLAAAATCxoTDBpscBMCAssAACB0c3bzbRAAAB8OowD7AmJy9/MA/V1JHsYAAB69xAAAIHHjSwoAAOwRAADHGQAAPh0AABEA") \ No newline at end of file diff --git a/api/search.index/07aefe086e.js b/api/search.index/07aefe086e.js new file mode 100644 index 00000000..49962148 --- /dev/null +++ b/api/search.index/07aefe086e.js @@ -0,0 +1 @@ +rn_("AUA7MAAAAQAAdwACAPsDdgASHQAAwckAAFcbBQLGAAAgQh+hIGIVHxYfFx8YH1ogpCBzdRUBxgAAEKsecyAdEwICwQAAG29wdeLKHAAApR4AALMgAABCAAUCyQAAFJQgFZ0gbHIVAcEAABuIIHTTHPMAguYCDQAAgxgAAH4eAADKHgAACB8AAI0gAACYIAAAEDAAAAUCwAAAEQ0SEHQgb3PBxAAAYx6hxQAAlhklAcYAABbSH3eOHdkeocEAAN8KBQLAAAAcgyASuRpzdAUCwgAAFrQfG5MgY3IxwAAAYRt8HWgeBQLAAAATsyASCh9kdaHFAACMHfMAA2hvc+SWCAAAExUAAIQXAABkHQAAWR4AACJBACWIxAAAE6YgoR/BHwEA/X25B0ny0hyGXqUZOXrGAAAfxsMAAB+fxQAAIGkw+FDlPRnqBTV6AvV3dlcRO/0HZXCUpMVubUkMD/ozrcxOzC+lrZFdKCg4O4E6MAAAAQAAAAAAJgAQAAAAfAZ9Bn4GIAtiDawNaBAiEiMSXBRcFl0WXhajF6QX5heNGI4YoxgzGTQZNRk2GX0Zfhl/GdMZoRqiGq4cjh4EHwkfWR9kH3kfih9yILcgAUY7MAAAAQAA9QABAIgW9QDxAP0RQUgAARETFBUB3goAAA==") \ No newline at end of file diff --git a/api/search.index/0822d9ad0e.js b/api/search.index/0822d9ad0e.js new file mode 100644 index 00000000..2424f5c4 --- /dev/null +++ b/api/search.index/0822d9ad0e.js @@ -0,0 +1 @@ +rn_("BYzAAAASohkgMAnUCwUCwwAAHcYfQ50cnhyAIIEgbnShxwAAmxyhwgAAOgsFAsEAAKGnDRVNGWxzVwcA/kBEkAvHAAAbSMQAABZswwAAHNrprgYAAEENAABSFgAAkhgAAKEYAADzGAAA5xkAAOkZAAB2HAAAPh4AAHAAAAAA") \ No newline at end of file diff --git a/api/search.index/08a8e37ba8.js b/api/search.index/08a8e37ba8.js new file mode 100644 index 00000000..4fc0c615 --- /dev/null +++ b/api/search.index/08a8e37ba8.js @@ -0,0 +1 @@ +rn_("ocQAADgdZQHAAAAZPR9mORw6HDscPBzeHmwgYwLAAMjEAAAfnfi6EAAAPAQtAMQD0gFGAlQApgEFAsAAABiMHhuDIGF0AUA7MAAAAQAAiQABADwTiQArisMAAB0oIcIAAIwfnh+PAv4ASUgCxgAAHpvAAAAbdsAAABilwwAAHpJhwgAAYApvFHAUcRRyFKsYAQAB4AAAHmpzOzAAAAEAAAEBBwA7CQAA7Av1AGgNAgBBEAAAcxACAI8YAADDGgIAvwPAFMbBAAAdXw==") \ No newline at end of file diff --git a/api/search.index/08c3cbd07d.js b/api/search.index/08c3cbd07d.js new file mode 100644 index 00000000..81a4be7b --- /dev/null +++ b/api/search.index/08c3cbd07d.js @@ -0,0 +1 @@ +rn_("BQLAAAAVGBkW4B5sbwcEAP0VsQPEAAAausUAAB0yxQAAHQnHAAAcQckAAB/CyAAAGefKAAAcf8MAAB1a89gHAADsEAoA") \ No newline at end of file diff --git a/api/search.index/0901d6b7b8.js b/api/search.index/0901d6b7b8.js new file mode 100644 index 00000000..08acb0f0 --- /dev/null +++ b/api/search.index/0901d6b7b8.js @@ -0,0 +1 @@ +rn_("ocQAAEsZIccAAKkcBh8FAsAAABt/IBIPHXJ0G4nKAAAgcysCyQAAIGdldDHHAABVHsQeyR81AcUAABbQH3eDHWsemR8FAsIAABhmIBp+IHJ1+4kVgsMAABQyH3sfE4GDxwAAH6rXAQPAEmLHAAAfPuHdFwAACx0AAEAVgsEAABStGwkS+wJvdqOB/RABBcAAAB9BxwAAH6YbAscAAB9kY3gBSDswAAABAAB6AAEA1R96APMCAWSGZRYAAPMAk4NzBAAA") \ No newline at end of file diff --git a/api/search.index/0907bed1c1.js b/api/search.index/0907bed1c1.js new file mode 100644 index 00000000..53e9567b --- /dev/null +++ b/api/search.index/0907bed1c1.js @@ -0,0 +1 @@ +rn_("BYbAAAAV1BcSXA0VAcIAABZhIHI/HxuJxAAAHF5DAAPLAAAen8IAAB9+ZWx0OjAAAAEAAAAAABYAEAAAAAENQg1DDdMXrRqwG7QbbhySHKocqxysHOwc7RzuHHQdJB5gHmkfeB+gH2QgpSBhwwAARBBFEMsTzBlIG20bGwPIAAAdgM8AACBbYWxtBQLAAAChSRERsBBpbvuDBQLAAABCDB9zH3QfZSCjpRxpbCsCwQAAGR9sbwUCxAAAFCodFfIeYXfzAAFu8kscAAC0AgUCwAAAF24ZFAwSZnRbwggEwQAAEGwBAQLAAAANY+AAAAuScnPpXgoAADYQAAAYEgAAPxIAAHYUAABUGQAAyhkAAHscAADrHAAAGR0AAAACABAABYbAAAAR2xQiYRBIEQUCwQAAFHwbFXofZWYFAsAAABKSGBePIGV5FQHHAAAQQB9z7BnDAMAOQMMAAB8uygAAIIXzmhgAANIDPQL7AmdpFQHAAAAXUx5jnCDzAwFzAREdAAD7wASEBYnBAAAYpBtJOx08Hcofyx8FAsAAABINHRF3HGl0FQHDAAAUbxlnwxkBReFwGAAALx4AAEMFAsAAABiAHBNvGG5zBQLAAAAQ8R4jeByCHXJ21wMA/TChBMgAAB+j5p4IAAB9FgAA7hgAAKEcAAAmHQAA9h4AAIkgAAAyMAAABYnCAAAcwR8UYRsjAP00AAjFAAAbiMIAAB0+xgAAHovi9AoAAHkUAAAPHwAAEQABQPjMBwAADRGYA2AAXQEaAUEAFgAhyAAAoR/BH/vEBiBF/cMDgAIAACU7H1IgM8EeyB9fICOBHcIeFTwfyR4QH3UghCAVAcMAABCtH3N/HTHEAAB9G6QciB/b/QQADcAAABIHAQD9fekHPp+ePg3FAAAePcIAAA1bf4X0rD9oT8eSj8wAAB6eRk1tw69pTCAlFMgAABx2UWutyqt6+zkH3UsotmC/VnDp4nlCMPBjUjswAAABAACaAR8AUwEAAFgBAABoAfUAYAIAAO0DAAByBgEAtgYBAE4LAwAMDQIARxAAAFQQAQAdEgEAKxIBAMMUAQD0FAEASxYAAKIXAAC9FwAA7Rd3AJMYAADcGAEAMhkAAGUZAQCZGwEAQhwAAD0eAABsHgAAnh4BAL0eAADFHgAAWyAAAA==") \ No newline at end of file diff --git a/api/search.index/091c4724cf.js b/api/search.index/091c4724cf.js new file mode 100644 index 00000000..9eb30b9a --- /dev/null +++ b/api/search.index/091c4724cf.js @@ -0,0 +1 @@ +rn_("IcQAAEQWSR4FAsQAABK/HqdlH2lyBQLBAAAdxh9DnRyeHIAggSBudCHGAABuHHgfAQMBxQAAHwBy4/AZAABTHwAAfR8AAKggAAAUAKHBAACGFvvAAEoVgcYAABL+HoodIwECxAAAHRlod/KfFwAAWwUBAQHFAAAeX3Li2hgAAOEeAACgIAAAFAD7AnJ0+wNhb3IhxAAAVRmsHgUCwQAAGr0epZkbbXP7AmVsBQPAAACqOh4UkhwTqh9hY3IVAsMAACcUHWAgHY4gbXAiH6HFAACFHPsCb3IVAcQAABatHnKHHyHCAACyECQS99QA/TegXckAACClxQAAG5PFAAAch9AAACCJ6qkYAABFGQAAsBkAAMQZAAAxHAAAXhwAAGYcAABPHQAAWB0AAJodAABJHwAAA4EAAAAQ") \ No newline at end of file diff --git a/api/search.index/092349c407.js b/api/search.index/092349c407.js new file mode 100644 index 00000000..a6cc3002 --- /dev/null +++ b/api/search.index/092349c407.js @@ -0,0 +1 @@ +rn_("RQLAAAAVYR8YBB9idCcZKBkpGWogJQHBAAAn0hyrH3TJCwodscEAAMMaxwEA/QohAsUAABnP4QAAEJbnBQsAAOwUAACYGQAArBoAAIkbAACGHQAAPB8AAFcgAAADAAAA") \ No newline at end of file diff --git a/api/search.index/0993a61244.js b/api/search.index/0993a61244.js new file mode 100644 index 00000000..2413a215 --- /dev/null +++ b/api/search.index/0993a61244.js @@ -0,0 +1 @@ +rn_("QcMAAHANcQ1yDWkeMcQAAK8eNB9sHxMAAskAAB5qZWbnpxcAALkXAADpGAAAexkAAJUcAAAzHQAAjB0AAHwfAAARERAQEwACwwAAHzJkZQF7HwAA+5I=") \ No newline at end of file diff --git a/api/search.index/09af2b6501.js b/api/search.index/09af2b6501.js new file mode 100644 index 00000000..fc0f8f4a --- /dev/null +++ b/api/search.index/09af2b6501.js @@ -0,0 +1 @@ +rn_("ocQAACkecdAAAKkgqiCrIKwgrSC2ILgg8wACc3b3/xEAAJoHXANrACECLQD4AAWMwAAAFx8eNGgZaRnyHhUBxAAAFlgecpQZZQHCAAAQhSBzFR8WHxcfGB9aIKQgBYnAAAAiuhgNHxlRIAUDwAAAE6oaFmcfE6saYW1yFYjGAAAfsiDLHjcCAP0RQQ3GAAAggsMAABqsxgAAHH7DAAAfD/t2EAAAkgiYAs0CBABSAEsANAAYACMBJAAFAsAAABH5GBBMHG93K4rDAAAZZxUCxgAAJFceUCAW/B5zd+QZFQHHAAAWYSByPx8TAgLFAAAdhWFyAfgYAABLwASE5gAAFhrBAAAXj6HDAAC4GCHDAAC3GAIfMcgAAH8fgB+uIDUBxAAAElYeY3ceeB6WIPMAAmls4aYbAAAkHQAAIPvBBCBByAAAfRxDH0QfpyChxAAAkhkFAsAAABORGRMqHWxzAQL+wE/EH1kONAXYCYK8jy3HAAAfR3H6UdHhIc8cTb3EAAAeiXoE5ipHOjSBZ47DAAAeOQU0+7y3OrmjbrjCAAAXkG9+iDH6O4Xzprtns4sZSO1FDQAAqhgAAK4ZAADRGQAACBoAAA4aAACkGgAAYhsAAGobAAC1GwAA6hwAACsdAABSHgAAdR8AAFAAEAAAAABhxwAAUxxUHFUcVhzAHm4gockAAIsb8wEBcjswAAABAAB8AAIAYgp6AOEKAQBrwQAGwQAAHlP5AwAEG2lveQ==") \ No newline at end of file diff --git a/api/search.index/09ca4f203f.js b/api/search.index/09ca4f203f.js new file mode 100644 index 00000000..5578b260 --- /dev/null +++ b/api/search.index/09ca4f203f.js @@ -0,0 +1 @@ +rn_("McQAAP4UYhzHH0HJAACLG4wbgxyEHAUCwAAAGYweEvYUc3SHAQD9RQEDwAAAEErLAAAe/8YAABzQxAAAGa07MAAAAQAAgQAEAGIKegDhCgEALwsAAMMLAwA7/VAgCMEAAB9j1QAAILM=") \ No newline at end of file diff --git a/api/search.index/09f79005de.js b/api/search.index/09f79005de.js new file mode 100644 index 00000000..ff0d7e6c --- /dev/null +++ b/api/search.index/09f79005de.js @@ -0,0 +1 @@ +rn_("occAALQeIwT9MAEFyQAAIHHBAAAU/8UAAB82xwAAH8PsGQ0AAJ0NAADtGAAAnxkAAGQbAACQGwAAgBwAACkdAACGHwAAjh8AAIcgAACiIAAAryAAADEAEAAEAAA=") \ No newline at end of file diff --git a/api/search.index/0a05c7d055.js b/api/search.index/0a05c7d055.js new file mode 100644 index 00000000..b1ff9c71 --- /dev/null +++ b/api/search.index/0a05c7d055.js @@ -0,0 +1 @@ +rn_("BQLAAAASiBsllBvXHGVyKwLMAAAfuGZ0S8AEBsIAABthxAAAHzUFicAAADSVHJYcfB8bkCAlAcIAACfSHKsfdMkLCh0BggLPAAAgnsgAAB+3YXMbAskAAB/BY2QbAsMAAB9cbnT/AP2RQEDIAAAeNQ==") \ No newline at end of file diff --git a/api/search.index/0a065efb70.js b/api/search.index/0a065efb70.js new file mode 100644 index 00000000..2f1dd26f --- /dev/null +++ b/api/search.index/0a065efb70.js @@ -0,0 +1 @@ +rn_("IckAAGIcxx/ByAAANB0FBMMAAFO2HLccuBy5HHkgF4cgHoYgF+4eaXBxclOCA8YAABy6YmVsYcMAAFAUURRSFFMUch3nHhUCxAAAFC4fGI8gY3NsHPsCbnIhwgAAhRZpHP8A/RFIAcgAABw3") \ No newline at end of file diff --git a/api/search.index/0a82f022a9.js b/api/search.index/0a82f022a9.js new file mode 100644 index 00000000..949b2cb7 --- /dev/null +++ b/api/search.index/0a82f022a9.js @@ -0,0 +1 @@ +rn_("BQLAAAAbfyASDx1ydBuJygAAIHMrAskAACBnZXQxxwAAVR7EHskfNQHFAAAW0B93gx1rHpkfBQLCAAAYZiAafiBydfuJFYLDAAAUMh97HxOBg8cAAB+q1wECwBJixwAAHz7h3RcAAAsdAABAMcQAAIgYiRj3HiHDAABdH18fBQLAAAAx5xnpGXYcNK4YwxxdIGVzGwLNAAAgaGV0IcgAAKAbcR6xxAAAxRfzAP4AAwAN5g0JAACMEAAABhIAALoaAAAFHwAAOx8AAFIgAABmAAAA+4Y=") \ No newline at end of file diff --git a/api/search.index/0a9413f8a7.js b/api/search.index/0a9413f8a7.js new file mode 100644 index 00000000..77e8d3a5 --- /dev/null +++ b/api/search.index/0a9413f8a7.js @@ -0,0 +1 @@ +rn_("BcDCgmAAABSPIBKzHhV5HCV6HJsdBQLAAABE7BztHO4coB8RkhhjZRcCAP0QBlTIAAAf08EAAB5cxgAAHKfFAAAeZzswAAABAAAuABQAsA0DAEsQAQBVFAEAsBgAAOgYAACDGQgA6BkAAHobAQCiHAEAZx0FAJYdAgCHHgEA7B4AAIQfAACYHwAAuR8CAL4fAABTIAIAdiAAAHggAAA=") \ No newline at end of file diff --git a/api/search.index/0cf135248a.js b/api/search.index/0cf135248a.js new file mode 100644 index 00000000..c0c7d8de --- /dev/null +++ b/api/search.index/0cf135248a.js @@ -0,0 +1 @@ +rn_("NQHEAAASXB5r6BmEH5gfI4ICxgAAHohzdAFG4dwcAAA5HwAAsCsC4gAAFGpkbQGCAtEAACCcxgAAHodlc1cBAP3AIQbHAAAdE8IAABiAxgAAHk/6JwsAAA4AfwABABgAAQCPCFQEcAV/AA==") \ No newline at end of file diff --git a/api/search.index/0d3e76a70d.js b/api/search.index/0d3e76a70d.js new file mode 100644 index 00000000..3e78c0da --- /dev/null +++ b/api/search.index/0d3e76a70d.js @@ -0,0 +1 @@ +rn_("IccAAKkcBh8FAsAAABt/IBIPHXJ0G4nKAAAgcysCyQAAIGdldDHHAABVHsQeyR81AcUAABbQH3eDHWsemR8FAsIAABhmIBp+IHJ1+4kVgsMAABQyH3sfE4GDxwAAH6rXAQPAEmLHAAAfPuHdFwAACx0AAEAVgsEAABStGwkS+wJvdqv9EAEFwAAAH0HHAAAfpksDyQAAHiDKAAAe/2FvdA==") \ No newline at end of file diff --git a/api/search.index/0d6270e3fa.js b/api/search.index/0d6270e3fa.js new file mode 100644 index 00000000..fbf2ddee --- /dev/null +++ b/api/search.index/0d6270e3fa.js @@ -0,0 +1 @@ +rn_("BQLBAAATSBsZgB1hbAUCwwAAFHwbFXofZWZBwQAA2RjRHEgfiR+zAP0QIQHIAAAcduZOCwAAHRIAAPQUAABLFgAAvRcAANwYAADFHgAAMRABAPMAAWTl7QgAAIcQAACuFAAAvxcAAOoXAACcHwAAAAUA") \ No newline at end of file diff --git a/api/search.index/0dd97116f6.js b/api/search.index/0dd97116f6.js new file mode 100644 index 00000000..da26d49a --- /dev/null +++ b/api/search.index/0dd97116f6.js @@ -0,0 +1 @@ +rn_("YcYAAAwd/B5qH2sf0R/SHyHEAABXHlAg8wICc3fo1hQAAKwXAADQFwAA5BkAAI4cAACIHQAAjh0AAGEeAADZHgAAAQARAAA=") \ No newline at end of file diff --git a/api/search.index/0dee998078.js b/api/search.index/0dee998078.js new file mode 100644 index 00000000..6ceeab9b --- /dev/null +++ b/api/search.index/0dee998078.js @@ -0,0 +1 @@ +rn_("BQLAAACxfRkYkh5kdqHSAACAICsCxwAAHr1sbqHFAAD7GBUBxgAAE34fbCQeIwICwgAAHC5scwEkFgAAK4bAAAAQSSMAAuAAAB+kc3QBWAoAAKHFAABPHAUCwAAAES8ZFkgdcnMFAsIAABMWHRMXHWxwxwGB/gAKgAzCAAAZrMkAAB8aBQLBAAAQzh8RjxhkafsCc3rzAISCFAkAAKvAAoXgAAAUbsMAAB5h9wAA/RFBEdEAACBjxAAAHv/yfA0AAOgI") \ No newline at end of file diff --git a/api/search.index/0df1d8f0f2.js b/api/search.index/0df1d8f0f2.js new file mode 100644 index 00000000..310b94e2 --- /dev/null +++ b/api/search.index/0df1d8f0f2.js @@ -0,0 +1 @@ +rn_("BQLEAAAyfx+AH64gELEYaXkBAMEAYMAAAA1j4AAAC5LBAAAYp+rdAwAAXgoAADYQAAAYEgAAPxIAAHYUAABUGQAAyhkAAHscAADrHAAAGR0AADAAIAEAAAkCxAAAGb/PAAAgZXN0e4/GAAAbpQ==") \ No newline at end of file diff --git a/api/search.index/0e853b4a3d.js b/api/search.index/0e853b4a3d.js new file mode 100644 index 00000000..36fb4c5a --- /dev/null +++ b/api/search.index/0e853b4a3d.js @@ -0,0 +1 @@ +rn_("FQHDAAAVfB1jbhkFjMAAABcfHjRoGWkZ8h4VAcMAABZYHnKUGaUBwwAAHHIgdNEX+4VlAcEAABCFIHMVHxYfFx8YH1ogpCArAsUAAB9RcHIBQuKuFwAAfxwAAIAdAABQABuGwQAAHo0hwgAAuhgNH1sDyAAAIFFhb3IFA8AAABOqGhZnHxOrGmFtchWIxgAAH7Igyx43AgD9EUENxgAAIILDAAAarMYAABx+wwAAHw/7dhAAAJIImALNAgQAUgBLADQAGAAjASQABQLAAAAR+RgQTBxvdyHEAADfGGcZ+4oVAsYAACRXHlAgFvwec3fkGRUBxwAAFmEgcj8fEwICxQAAHYVhcgH4GAAABQLAAAARjxegKAtmZ2vABITmAAAWGqHDAAC4GCHDAAC3GAIfMcgAAH8fgB+uIDUBxAAAElYeY3ceeB6WIPMAAmls5ecUAAB0GQAAphsAACQdAADCHwAAViAAABAgAPvBBCBByAAAfRxDH0QfpyAFAsAAABdHHx3THm9yocQAAJIZBcDAAEUAABORGRUfGRMqHQEA/sBPxB9lmJogggmCvI8tP2z6GaJx+lHR4WzxYMYxxAAAHolfBYW2Wzo0gWeOwwAAHjkfBnOwIh/eSS6FwgAAF5AWsSYpzkR26u9uZ7OLGUg6MAAAAQAAAAAAHQAQAAAAdAZ1BnYGdwZ4BscLyAtFDUYNRw1IDUkNSg2qGK4Z0RkIGgkaDhqkGmIbahudG54btRvqHCsdUh51H9MfIcQAADsfUiC5/QwADAAB4AAAB9Yh") \ No newline at end of file diff --git a/api/search.index/0ef9cee81e.js b/api/search.index/0ef9cee81e.js new file mode 100644 index 00000000..c4cd7a53 --- /dev/null +++ b/api/search.index/0ef9cee81e.js @@ -0,0 +1 @@ +rn_("BQLAAAAXLRwTPR1hckOBwA4AwwAAH6PCAAAcyBWBwAAAFN8elxyhwgAAQhBFkMUAAKKvHI4XKR8qHysfFQHGAAAQbx5zfBzzAwFw4goSAAAZFgAAoh4AAAAg84EDcHN1+wJjZBcBAP0QUAPFAAAe/8sAAB4hyQAAHE71tgsAALgEXgQJA8YG") \ No newline at end of file diff --git a/api/search.index/0f2423b5be.js b/api/search.index/0f2423b5be.js new file mode 100644 index 00000000..543acbd6 --- /dev/null +++ b/api/search.index/0f2423b5be.js @@ -0,0 +1 @@ +rn_("JQHAAAAT6xdwZRipGgWFwAAAF7EeF7IeIcEAAL0YER8XAwD+QoIEA8AAABwt4QAAEi3GAAAfuMQAABj69ggJAADhAigG7wLUAlEH") \ No newline at end of file diff --git a/api/search.index/0f4a6a1a42.js b/api/search.index/0f4a6a1a42.js new file mode 100644 index 00000000..1f7adcea --- /dev/null +++ b/api/search.index/0f4a6a1a42.js @@ -0,0 +1 @@ +rn_("BQLAAAAXEB0S6RdpcoMC/RAQBcUAABuwyAAAHYTKAAAerjswAAABAAAnAAMAHgsAAGILIwBYFAIAGwLEAAAfYHV2") \ No newline at end of file diff --git a/api/search.index/0f8cc917e3.js b/api/search.index/0f8cc917e3.js new file mode 100644 index 00000000..2c3564a6 --- /dev/null +++ b/api/search.index/0f8cc917e3.js @@ -0,0 +1 @@ +rn_("ccAAAOQK5QrmCucKShleH50fBQLBAAA0IR1wH3IfdXobexuiHKMc7B52IHggc3TBwwAABBoFAsAAABoKHyB4EJofcHKxxwAAdxs1AcMAABZkIHObH6kfniDzBAFk0poeAADo5wkA/R44AscAAB9IyQAAHwjJAAAfCYM9DQAAIcAAAIEdwh77Am55+4M=") \ No newline at end of file diff --git a/api/search.index/0fa579b444.js b/api/search.index/0fa579b444.js new file mode 100644 index 00000000..08f10336 --- /dev/null +++ b/api/search.index/0fa579b444.js @@ -0,0 +1 @@ +rn_("ocQAAEsZQcIAAMoLywthFGIUAUD4oggAAFkAoRC6AR8C7gEpABIAAUDnZhgAAD8dAAB6HgAAMx8AAFgfAAB3HwAAcCAAAJcgAAAyMAAAIwACwgAAFNppc/IcCwAAygkhxwAAqRwGHwUCwAAAG38gEg8dcnQbicoAACBzKwLJAAAgZ2V0MccAAFUexB7JHzUBxQAAFtAfd4Mdax6ZHwUCwgAAGGYgGn4gcnX7iRWCwwAAFDIfex8TgYPHAAAfqtcBA8ASYscAAB8+4d0XAAALHQAAQBWCwQAAFK0bCRL7Am92o4H9EAEFwAAAH0HHAAAfptvEBiHgAAAZoMcAAB9kAUg7MAAAAQAAegABANUfegDzAgFkhmUWAADzAJeDcwQAAA==") \ No newline at end of file diff --git a/api/search.index/10a382eb4d.js b/api/search.index/10a382eb4d.js new file mode 100644 index 00000000..984b86fd --- /dev/null +++ b/api/search.index/10a382eb4d.js @@ -0,0 +1 @@ +rn_("JQLBAAAWcR0UZhxydHUbSBwrAsoAAB94Y20bAsYAABxnaXaxyQAApR4VgcQAABHwHEsfBQLAAAAR2h4RNh5ydPsDYW9yIcIAAGgbGh0FAsAAABj8HC5eILEgcngFAsUAALOiHrUpH3BzdwgA/RgxH8oAAB8syQAAHvfLAAAfYsQAACB3xAAAGcLBAAAWXwHSCwAABYrAAAAlXx5hIKPEHBUBxgAAGMMfdIYfNQHGAAAfpSB00hkfH9QfBYnBAABi4R7iHuMe5B7lHqAgZFMfVB9VH1YfVx+oIGHHAACOH48fkB+RH5IfryDzhgJwc88B/RFIAcMAABmNzAAAH6slhMQAABSeH3kdnSCxwgAAvxT7AmRlJYTEAAAUYx8IHZQgFQLDAAATUB0W3R5tczcZe/1ACAnEAAAZwQUCwAAAJ2UcfyAZRR5udiuKyAAAIFbRwwAADRUVAcMAALDNF3MBEjMBA+YAAAtHaW5z4voIAAA/CQAAyBcAAANAxYHAAAARgBZKHbHCAAB9FvsCZGcFAsAAABFNFhhJHW1vE4H9FMAAwAAAEFzBAAASIMIAABiQ+wNhbnMFwMMASgAAGsIfF+AeFFodKwLCAAAdcXJ0AQD9CAESxQAAH7bCAAAfG8IAABe0xQAAHzfmog0AAJEbAADUHAAAVR4AAMQeAADuHgAAyR8AADEgAAAhwwAAQhmdHQUCwAAAEMIUG3AgdHVTAAPGAAAddGFjdOE9CwAAJRkAABA7xQQgxgAAHGTFAAAfESHHAADRGSsdNcLDAkAAACUUH80fJP0Z0B4WmBwqEnsWfBYbiccAABzvBQLBAAAZRB4emx9kdAEA/sBnvBscAYuhgMAAAAvja/37lH1TiO/3FQ9XV3PGUMeLrowmIKJYVVnB+hKYzAAAHyVr+yXMdAFSi2MeVCDqhNop2Ig8In/luxm9ygAAHzrFAAAVEu2nBgAA8QcAANYIAAD6CgAAaRAAAPYRAADdFAAA2hgAAE8ZAAAwHQAA2h4AAOEeAAA/HwAAoCAAAAFRIwEABAA=") \ No newline at end of file diff --git a/api/search.index/10b6d4942e.js b/api/search.index/10b6d4942e.js new file mode 100644 index 00000000..88d12bbb --- /dev/null +++ b/api/search.index/10b6d4942e.js @@ -0,0 +1 @@ +rn_("BYnAAAAeOh8dBx8rAscAABujZXQFAsEAABuUHRMDEmFzJQHGAAAWDB131hTQFwWMwgAAEUISEhoVMcUAABkVsRqoHLHCAABiGRUBwAAAHc8fYpQd84aGpx4A/gB1hgnNAAAfBsgAABuiyAAAGgrLAAAdleP3AwAA8RQAAIoYAABcHAAAMQA=") \ No newline at end of file diff --git a/api/search.index/10c65754b1.js b/api/search.index/10c65754b1.js new file mode 100644 index 00000000..143c6422 --- /dev/null +++ b/api/search.index/10c65754b1.js @@ -0,0 +1 @@ +rn_("BQLBAAAbUR8UUhtsdgEC/sBASALIAAAfUNEAACCSwgAAHFjJAAAfisMAABzZxAAAHU8BURYAABUBwwAAEIYeZJUY+wNhbHI=") \ No newline at end of file diff --git a/api/search.index/10e759fe8a.js b/api/search.index/10e759fe8a.js new file mode 100644 index 00000000..384e5b0f --- /dev/null +++ b/api/search.index/10e759fe8a.js @@ -0,0 +1 @@ +rn_("FQLCAAAWcR0UZhxydHUbGwLGAAAcZ2l2IcYAAF8eYSAVAccAAB+lIHTSGQUCwQAAJ2UcfyAZRR5udgUCwQAAsn0WEoAWZGelgcAAABHCFD0LGwLGAAAcZGR2IccAANEZKx0FAsEAABlEHh6bH2R09xcA/kBGpAvFAAAgd8oAAB865/EHAADWCAAAaxAAANoYAADaHgAA4R4AAD8fAACgIAAAFQEEAA==") \ No newline at end of file diff --git a/api/search.index/1150e98586.js b/api/search.index/1150e98586.js new file mode 100644 index 00000000..155584c7 --- /dev/null +++ b/api/search.index/1150e98586.js @@ -0,0 +1 @@ +rn_("IcIAAGEQSBFFAcAAABJWHmNRHXceeB6WIDHCAACzHLQc7R4VAccAABZhIHI/HzcBAf0BgQfEAAAbfsUAAB2FyQAAH6jkBBIAANsUAAD4GAAAsBoAAF0dAAAQABD7hg==") \ No newline at end of file diff --git a/api/search.index/11d8847ca7.js b/api/search.index/11d8847ca7.js new file mode 100644 index 00000000..c4c331c2 --- /dev/null +++ b/api/search.index/11d8847ca7.js @@ -0,0 +1 @@ +rn_("BQLAAAATpSAQMR1vcxOBAsIAAB89Y3LBxwAAlR4hxgAAMhymHWHHAAAGGQcZOx08Hcofyx8hwwAADRmxGwFGOzAAAAEAAHcAAQDtF3cA1wsA/gCNYQHBAAAZLsMAAB+LOzAAAAEAAPIBBQDaAwAAfgT5AEMLAABGDvUAShsAAA==") \ No newline at end of file diff --git a/api/search.index/123c2f1b75.js b/api/search.index/123c2f1b75.js new file mode 100644 index 00000000..11452f62 --- /dev/null +++ b/api/search.index/123c2f1b75.js @@ -0,0 +1 @@ +rn_("ocQAACkeFQHCAAASfhhsbw1x0AAAqSCqIKsgrCCtILYguCAFAsAAAFmvH7AfsR+yH7QgGSUdbHIVAcMAABOEG2SVFwUCxQAAFIYdFIcdb3QVhMIAAKFEHJQXNQHAAAAQcB5zOxY8FqQd+wJkdAFE43AZAAC9GgAAnxsAAEIeAAAUABsCwwAAGXJsb3cBAv0EkQjKAAAeW+VtDQAABhkAADsdAABMHwAAuB8AAMofAAAREAHzAMEIyPf/EQAAmgdcA2sAIQItAPgA") \ No newline at end of file diff --git a/api/search.index/13a7ea9919.js b/api/search.index/13a7ea9919.js new file mode 100644 index 00000000..83ca8dbb --- /dev/null +++ b/api/search.index/13a7ea9919.js @@ -0,0 +1 @@ +rn_("BQLAAAAayB4z5xfoFzUebXAjAP0REBPGAAAgkcUAAB5hwQAAGd7gAAANY8UAAB5i5GoZAADIGQAAlBsAANccAABUHQAAEQAg") \ No newline at end of file diff --git a/api/search.index/14cbfde6c3.js b/api/search.index/14cbfde6c3.js new file mode 100644 index 00000000..bf3733a7 --- /dev/null +++ b/api/search.index/14cbfde6c3.js @@ -0,0 +1 @@ +rn_("BQLAAABG0R7SHkUfRh8TEBJucwWGwAAAGnsdFnMdBQLAAAAeaR8V4RhocqWBxQAAEV0ctRcFAsUAABhaHxUeHWRyCQLFAAAgo9AAACBxb3JRxQAAuxy8HL0cvhx6IFHEAAA5HDocOxw8HGwgBQLAAAAWYiAanB50dZc9Af7AQnQDzQAAH2jGAAAbYsQAABlE81MZAABJBPEBMcAAADoeOx6KHhMBAsUAAB6wc3b/KAkAAAEAAQDSAQICBQW1BScBiAAFABMC+gClAAcAkgFrwAFrwgAAF9XUAAAgscMAABeWwQAAEzpBwQAAhguHC1sdth8FwMAcAAAAIvYeiSARcxknxR9oICsC4AAAGaRkbiMChcAAABxd5LUXAABTHAAAGB0AAMAeAABuIAAAEwAAa4vFAAAfBAUCwQAAHpgfH8UfbHMhxAAAnR5bH/sCY3IFhcYAABgvHxR1HVWCwwAAF2UgMxw0HDUcNhxrICsC4AAAFK1kdAUDwAAAELwXULsX/RmSHdAeUSAxARU4HJMgZXN1ocgAADceW8AAoccAACB31wMA/RFFEcAAAB+mxwAAHVw6MAAAAQAAAAAAEgAQAAAANws4C/cU+BTlGe0Zjxt1HIccfR2FHYsdWB5fHq0eth4AHzYfYSAFAsAAABfDH3O/F8AXwRfCF8MXxBecH2hvBQLAAAAVqR4WLxxlbDHBAADlGOYY1xk1AcEAABYBH3f3GfgZXxyhygAAbR/zhQJjZQUCwAAAKEEeWCCyhRtveaHGAAD5GQUCwAAAIaEYPh4mZh3AH2V0FwEA/RCADsUAABz2zAAAH8fDAAAdj+GpDQAAahgAACClAsUAABZhHhZiHm13yBmlAcYAABbRH3eIHQUCwAAAFQUfEQQVYWYBQOG0FAAASxsAABb7AmRuBQLAAAAjcRwuHhqfH25wBQLAAAClexkVnx5ob/ceAf4ARSwLyQAAH2Q6MAAAAQAAAAAAGgAQAAAAPAvWC9cL2AvZCycWKBZ3Fq4Y3RmjGkkbohujG2ccwxwhHU4dlR1EHkUenB4dHyQfcB9yH10gIcEAAAoVqB47A8AAABT/bm92occAALQeBQLAAAAZcSAXrh9wcgFC4bcLAAD7GAAAgdcnAP5ATfACwgAAGq/DAAAZP8UAAB827+gLAAAZDQAAnQ0AAO0YAACfGQAAwhkAAGQbAACQGwAANxwAAIAcAAApHQAAhh8AAI4fAACHIAAAoiAAAK8gAAADEAAQAABAAAWKwAAAop8YpWQbAQMCwgAAHRLDAAAfjGJp6yENAADiFAAAvxgAAH4cAACbHAAAVx0AAF4eAAC4HgAAux4AAPoeAABlHwAAzx8AABAAEAAQECHCAADrFwwa+wNhb3IFAsAAABNDGRhAHmNrCYPBAAAYnNAAACCFIcAAACANpg0nAwL+AEMiCcwAAB9ZyAAAHpvMAAAfl84AACBaAdMLAAAbwAAlwQAAGZnCAAAc/OHDAABtFgWKwAAAG9ceEgsaMcEAAIAQgRCaGUvQBATnAAARzMAAABeSNYjEAAAUEh/bGdwZ3R4lAsIAABYGHRZpH3Jz6xFxH1MAA88AACBqb3F1AegcAAChxQAASRwFAsAAABhfHxuaHmN1EwD9AQEWwAAAGPXgAAAe28oAACCIxQAAHkzj0AgAAAQNAAC5GQAA0xwAABAQBQLAAAARPh4WwB9ldBsCwwAAHY9ldCMEA8MAAB4uyAAAIFhlc3T0rhgAABUEggEYAkHCAADgGeEZ8xn0GQUCwAAAFAMaIEse9B5lcvuGAQQCwwAAG4DgAAAXjW9yOzAAAAEAAPAAAgDcFAAAJBXvABuDzwAAILX7Aml2IcYAAMoejSAFAsEAABY9HhRzH2NwMcEAAOcZ6Rl2HCUBwwAAE34fbNMXJB4bAuAAACCRb3MVAsMAABShHhaKHmlyrRl/H/0VsAfFAAAatwUCwAAAuKYbE/Meb3IhygAArx40HwUCwAAAFoEfFVMeY247/YFACMEAABlDywAAILcBggLQAAAgtcEAAB5LaXYVAcMAABWdH3D/HJOB/QCJCsUAAB6gxAAAHkrCAAAdADHNAAC6HrAgtSBrA8AAABTSZnV2AQD9v/1eSCWQxAnEAAAfYWdgT/nxY6NYDUlJl5PbAAyMxIGhQUVIofFWBKE4d8cAAB5AZYMNzNDNAAAfzUL3ajJxB/ymFWJ0+D4fmRKpXf6/Vwh5euM8x8z/slGOS8ZqeaaLSPA6MAAAAQAAAAAAZQAQAAAA1AjxCPII8wj0CAwJMQlZCloKWwpcCl0KkgsCDQMNeRB6ELMQ6RHqEUwUTRROFE8UaBS9FD0WSBZJFkoWeBZ+F38XjBfXF9gX2RfaF9sXZRiDGJsYCRkKGQsZDBkTGRQZFRkWGScZKBkpGZIZkxmrGakaqxqcG6EbbxxwHKccsRy7HLwcvRy+HAMdBB0OHS8dOB05HVkdhB0gHl0ebx5+Hn8egB6BHo8eqx7KHuYe9R4IH0AfTh9/H4Afkx+VH2ogeiCKII0gmCCcIK4g") \ No newline at end of file diff --git a/api/search.index/1508aab767.js b/api/search.index/1508aab767.js new file mode 100644 index 00000000..a824504a --- /dev/null +++ b/api/search.index/1508aab767.js @@ -0,0 +1 @@ +rn_("AUDmAg0AAIMYAAB+HgAAyh4AAAgfAACNIAAAmCAAABAwAADBxAAAYx6hxQAAlhkHFgL9NaEDxwAAIKbDAAAfCsMAAB+fxQAAIGnCAAAaucEAABIN/yALAAA8CTEEAQATAgEADALgAXsAUAALABUAEQDoAEUA") \ No newline at end of file diff --git a/api/search.index/15cdd4a6d1.js b/api/search.index/15cdd4a6d1.js new file mode 100644 index 00000000..c3c411df --- /dev/null +++ b/api/search.index/15cdd4a6d1.js @@ -0,0 +1 @@ +rn_("BQLAAAAXEB0S6RdpcjHAAAA6Hjseih6HAQD+AASEC8UAABuwyAAAHYTKAAAersUAAB6wOzAAAAEAADYAEAAoCQIA/AoAAB4LAABiCyMA/gwAAAMSAABYFAIAuBcAAN8YAABnGQAAbBkAAH8bAAB5HAAAHh0AACUdAAC3HgAAAUSkexQAAKHGAAD0FFMAwQMAxQAAHQWC6AMAAAUCwwAAGScfGpofbXYFAsEAABNPGRKiGHB0jwHABmjIAAAdcMYAABtqywAAHvAFA8EAABYiHRcoHRcpHWJpcCsC4AAADPhydF8G/R0JBMUAAByCxAAAH4PAAAAYfg==") \ No newline at end of file diff --git a/api/search.index/16006c3766.js b/api/search.index/16006c3766.js new file mode 100644 index 00000000..2a5c5b24 --- /dev/null +++ b/api/search.index/16006c3766.js @@ -0,0 +1 @@ +rn_("BcjAggAAABNDGRhAHqdFHwmDwQAAGJzQAAAghSHAAAAgDaYNJwMA/gBDIgnMAAAfWcgAAB6bzAAAH5fOAAAgWgHTCwAAAUOkexQAAPuD") \ No newline at end of file diff --git a/api/search.index/16784189cf.js b/api/search.index/16784189cf.js new file mode 100644 index 00000000..b16222a7 --- /dev/null +++ b/api/search.index/16784189cf.js @@ -0,0 +1 @@ +rn_("BQLAAAAbfyASDx1ydBuJygAAIHMrAskAACBnZXQxxwAAVR7EHskfNQHFAAAW0B93gx1rHpkfBQLCAAAYZiAafiBydfuJFYLDAAAUMh97HxOBg8cAAB+q1wECwBJixwAAHz7h3RcAAAsdAABAMcQAAIgYiRj3HiHDAABdH18fUckAAHoeex58Hn0elyAFAsAAABFzGRdoIGZnKwLgAAAZpGRu8wSE4lMcAADAHgAAbiAAADAABQLAAAAx5xnpGXYcNK4YwxxdIGVzOwPEAAAXlmV0dSHBAABeILEgIcgAAKAbcR4FAsAAABXmFLPFF2hvtw8A/gAHgA/LAAAexugNCQAAjBAAAMkQAAAGEgAA/xQAALoaAAAFHwAAOx8AAFIgAABmAAAAAAFEOzAAAAEAAH0AAgAOEgEAzBN7APuH") \ No newline at end of file diff --git a/api/search.index/17a0c932c5.js b/api/search.index/17a0c932c5.js new file mode 100644 index 00000000..6cd18cf8 --- /dev/null +++ b/api/search.index/17a0c932c5.js @@ -0,0 +1 @@ +rn_("BYnBAAAy+xn8GfscEdcescMAAH0ZMcYAAJUclhx8H9MF/RQQAsUAAByX93kWAAABAO8EwwHyAQMAlgAbAsgAAB+NcHI=") \ No newline at end of file diff --git a/api/search.index/17d4c81987.js b/api/search.index/17d4c81987.js new file mode 100644 index 00000000..32b8e11d --- /dev/null +++ b/api/search.index/17d4c81987.js @@ -0,0 +1 @@ +rn_("ocMAAEkRocUAAKUcRf3DA4ACAAAlOx9SIDPBHsgfXyAjgR3CHhU8H8keEB91IIQgMcQAAH0bpByIH/sCY3WXAQD9EYgBwAAAHw/AAAAce4MMDQAA") \ No newline at end of file diff --git a/api/search.index/18be3ad151.js b/api/search.index/18be3ad151.js new file mode 100644 index 00000000..dd328439 --- /dev/null +++ b/api/search.index/18be3ad151.js @@ -0,0 +1 @@ +rn_("ocQAAJYZFQHDAAAW+x533xkhwwAArxt2HiUBwwAAE1QebtMe6B7zAMAIiIIhFQAAQ4MDwwAAGd7EAAAbf2Vyc2MAA+AAABxCZHR18kgQAAB0CA==") \ No newline at end of file diff --git a/api/search.index/19171651f5.js b/api/search.index/19171651f5.js new file mode 100644 index 00000000..ca3a5603 --- /dev/null +++ b/api/search.index/19171651f5.js @@ -0,0 +1 @@ +rn_("AUU7MAAAAQAAigACALMYAAAPGokAFYHGAAAYpiByIPMDggGvGAAABQLBAAAgrhTqFxRjG255FYTEAAATXx1JGZ8R/WSkDcIAABidygAAH2DFAAAdCsIAABqcxgAAHS8=") \ No newline at end of file diff --git a/api/search.index/1948a2e2df.js b/api/search.index/1948a2e2df.js new file mode 100644 index 00000000..8d1319fd --- /dev/null +++ b/api/search.index/1948a2e2df.js @@ -0,0 +1 @@ +rn_("YcYAAAwd/B5qH2sf0R/SHyHEAABXHlAgYwADxAAAGeVlc3fo1hQAAKwXAADQFwAA5BkAAI4cAACIHQAAjh0AAGEeAADZHgAAAQARAAA=") \ No newline at end of file diff --git a/api/search.index/196c32cb88.js b/api/search.index/196c32cb88.js new file mode 100644 index 00000000..8cef93be --- /dev/null +++ b/api/search.index/196c32cb88.js @@ -0,0 +1 @@ +rn_("BQLBAAAUix0VXh5xc/MAAXLsKA0AACcSAAAEFQAAjxcAAKEXAADYGAAAcxkAAOMZAAB2GwAAlRsAALYcAABuHgAAeSAAAAEAAAAAMAArjMMAAB6y") \ No newline at end of file diff --git a/api/search.index/19b8cd05d1.js b/api/search.index/19b8cd05d1.js new file mode 100644 index 00000000..da61d8fc --- /dev/null +++ b/api/search.index/19b8cd05d1.js @@ -0,0 +1 @@ +rn_("FYTFAAATYBy0HwUCyAAAFpkfF8kfbXIjgcAIhMEAABRLwgAAFnZRxQAAZhhnGGgYaRhYHxsD0AAAIGnMAAAeymFucxUBwAAAE7IacKoeIcUAAE0fUh8zgsAGQMIAABmaGwLIAAAfTmNwfwH9gUAJzAAAHnY=") \ No newline at end of file diff --git a/api/search.index/1a8b5a3737.js b/api/search.index/1a8b5a3737.js new file mode 100644 index 00000000..9021e737 --- /dev/null +++ b/api/search.index/1a8b5a3737.js @@ -0,0 +1 @@ +rn_("AQD9CAESxQAAH7bCAAAfG8IAABe0xQAAHzfmog0AAJEbAADUHAAAVR4AAMQeAADuHgAAyR8AADEgAAAVA8AAABxwIBd0HSNCGZ0dYWN0JRn7AmRy") \ No newline at end of file diff --git a/api/search.index/1a9dff962a.js b/api/search.index/1a9dff962a.js new file mode 100644 index 00000000..89f2d632 --- /dev/null +++ b/api/search.index/1a9dff962a.js @@ -0,0 +1 @@ +rn_("FQHCAAAd1B9y7xxlAsAAABHcFxSbGmFjPxBAEKYZTR4tH0cf8wABZAE1HwAA+4ZDAMAAwsQAABtByAAAHC/k8AgAAOQKAABKGQAAXh8AAJ0fAAADAAA=") \ No newline at end of file diff --git a/api/search.index/1b492a03da.js b/api/search.index/1b492a03da.js new file mode 100644 index 00000000..186f80b1 --- /dev/null +++ b/api/search.index/1b492a03da.js @@ -0,0 +1 @@ +rn_("QcAAAJ0cnhyAIIEgBQLBAAAUiRsUihtvdBOFAsYAAB1HZHAVAsEAACfqHFIeF+scZGWgDROBAsAAAA2hZXIrAs8AAB9ybnMFAsAAABEZGRP+HHJzCQLQAAAgsMIAAB5HaXYrAsoAACBvY3IbhsEAABxqBQLAAAAdjyATch1ucisDwQAAHGnNAAAgh2FlbBUCwgAAFV4fINAc/Rxwc2gcKwLgAAAY9GR0BQLDAAAWXSATXR9zdSOBAscAAB+cY3RfDf0FiQvFAAAftMcAAB71ygAAIJMrAskAAB6dbnQxyAAAlxeYFx4Z/wL9EUFIwgAAG5Y=") \ No newline at end of file diff --git a/api/search.index/1b8c1155e8.js b/api/search.index/1b8c1155e8.js new file mode 100644 index 00000000..be2c3699 --- /dev/null +++ b/api/search.index/1b8c1155e8.js @@ -0,0 +1 @@ +rn_("IcMAAEIZnR0VAsQAAB2jIByRIHJ0ihtLA8EAABkFwwAAG4lpb3QFAsAAABMNHxQPH3R18wYBcwGkGgAAFQHCAAAToh9kmxojgQLEAAAddG11BQLAAAAwxxPCFNwXG3AgdHU3GgD9NUEHwQAAHELCAAAYu9AAAB+mxAAAHrfuvAgAAD0LAAAfDQAAaw0AAD8QAADkEQAAJRkAAKYZAAD5GQAAQRsAAE0eAAAtHwAAQR8AAEcfAACmHwAAUQASABAAAAA=") \ No newline at end of file diff --git a/api/search.index/1bad396c7d.js b/api/search.index/1bad396c7d.js new file mode 100644 index 00000000..31437c20 --- /dev/null +++ b/api/search.index/1bad396c7d.js @@ -0,0 +1 @@ +rn_("IccAAL8YuB6hxQAAfBghxwAAdB9lIAUDwAAApHoYEzoWEVYeYW90UccAACUeJh4nHigejCB3DAD9NtANyAAAHz3BAAAQr8UAABnmxQAAIHzIAAAeeTswAAABAAAtABIAowgIAIIQAwCAFwAAzRcCADMcAwA5HAMAvxwDANAcAAD9HAAANB0DAFEdAQB3HgEAmR4AAKEeAADeHgAAayABAHsgAACWIAAA") \ No newline at end of file diff --git a/api/search.index/1bf055c1cb.js b/api/search.index/1bf055c1cb.js new file mode 100644 index 00000000..0b04f86f --- /dev/null +++ b/api/search.index/1bf055c1cb.js @@ -0,0 +1 @@ +rn_("ockAAPMcocUAAHcZAQMByQAAIKVm4bAZAABJHwAAgbHHAABUHfsCaGkFAsAAABSOIBRbHGZyAUw7MAAAAQAAegABANUfegAxwQAAWRZaFpEe8wH9AHAMOzAAAAEAACMADACADRcAYRAAAEgRAADVFAAAHBkBANIZAABbGwAAoBwAAOAeAAAfHwAA1B8AAKMgAAABQeWMFwAA1xcAACAeAABdHgAATh8AAIogAAAEAACvAv0RYILDAAAaCsoAAByfwQAAEUs=") \ No newline at end of file diff --git a/api/search.index/1bf0df3907.js b/api/search.index/1bf0df3907.js new file mode 100644 index 00000000..a5ed88ee --- /dev/null +++ b/api/search.index/1bf0df3907.js @@ -0,0 +1 @@ +rn_("AUE7MAAAAQAA9gACAPUDAADJBvUAocUAAE8cBQLAAAARLxkWSB1ycwUCwAAAExYdExcdbHAbAsQAAByyZm4jAAPDAAAarcgAAB/OYmVu4SUSAACNFwAAEAUCwgAApPMcFxofZXQVkMAAABKsGfAHQcQAAL0UqxlvHHAc5xoA/sAKkA3DAAAggsMAABuA4AAACNQ7MAAAAQAAZAIRALgGAQD1CAAAZRABAIAQAQBVEXYAQxIAAM8UAADcFAAAJBXvAD4WAAAFGQAAmhkAAKUZAAAKGgAAtht2AGocAACoHXYAMwAD4AAAA+JlaXoB4gMAAA==") \ No newline at end of file diff --git a/api/search.index/1c3d2e3d82.js b/api/search.index/1c3d2e3d82.js new file mode 100644 index 00000000..60ba5581 --- /dev/null +++ b/api/search.index/1c3d2e3d82.js @@ -0,0 +1 @@ +rn_("FQHAAAATnxx3BQ0bAsAAABZQdHgBQeQyEAAA/hEAAEUWAABzFgAASB0AACACAAUCwAAAE74YHaQfdHUhxwAAXiCxIPsCZHP7AmxtO4/AAAAfYccAAB/O") \ No newline at end of file diff --git a/api/search.index/1c69e0657e.js b/api/search.index/1c69e0657e.js new file mode 100644 index 00000000..c5a80bc8 --- /dev/null +++ b/api/search.index/1c69e0657e.js @@ -0,0 +1 @@ +rn_("UcsAAK8fsB+xH7IftCAVgcEAABSuGncQCQLUAAAgjsAAABxkY29RywAAeh57HnwefR6XIDHCAACGEOMRUR4FAsMAAGITGRQZFRkWGfUelR8YhB9pefvBAQJRwgAAPRw+HD8cQBxtIAFE5OMYAADUGQAAbh0AAOkeAAC9HwAAEhIAFYTEAAAk9h6JIBgdEwAD1QAAILHGAAAdc3R1d4LpEQAAUcAAAGoZaxlUHVUdVh0VAcYAAKADHXMAGgUCwgAAGGEfE6QfY20VkMAAABYHHcMfdw8A/gAK4A/HAAAdkcEAAB7H5ekIAAAkDQAAuQ0AAPYYAACnHwAAkCAAADABAFHJAAAzHDQcNRw2HGsgtwsA/ZFBScMAAB5PwQAAFoQBdxwAAA==") \ No newline at end of file diff --git a/api/search.index/1db3ed7e17.js b/api/search.index/1db3ed7e17.js new file mode 100644 index 00000000..b9053aab --- /dev/null +++ b/api/search.index/1db3ed7e17.js @@ -0,0 +1 @@ +rn_("BYnAAAAYpBtJOx08Hcofyx8FAsIAABPIHBQ9HWRmKwLAAAAYu2xyBQLFAAASah4Ycx9pcysCwgAAHolpdnv9AQEFywAAHws=") \ No newline at end of file diff --git a/api/search.index/1ddfda513b.js b/api/search.index/1ddfda513b.js new file mode 100644 index 00000000..3908ee5b --- /dev/null +++ b/api/search.index/1ddfda513b.js @@ -0,0 +1 @@ +rn_("McgAAH8fgB+uIDUBwAAAElYeY3ceeB6WIDHCAACzHLQc7R7zggJpc0vABITAAAAXkcQAAB/GwwD9AwkKwAAADS3AAAAZnsYAABy1xQAAHS7tzAcAAOcUAADZGAAAdBkAAKYbAABxHAAA0RwAACQdAAAuHgAASB8AAIkfAACfHwAAwh8AAFYgAAABACMAAAAA") \ No newline at end of file diff --git a/api/search.index/1de44449db.js b/api/search.index/1de44449db.js new file mode 100644 index 00000000..022411f5 --- /dev/null +++ b/api/search.index/1de44449db.js @@ -0,0 +1 @@ +rn_("BcLBEEAAABGCGBOlGRVLHBWExAAAE+sZhxgFAsMAABPIHBQ9HWRmK4XIAAAeajHDAACvHjQfbB8FAsMAABX0HhNGHnN1+wJsdrcBAP4ATJwJxwAAHMnOAAAfZ8gAAB0+wgAAFj7EAAAZLeenFwAAuRcAAOkYAAB7GQAAlRwAADMdAACMHQAAfB8AABEREBA=") \ No newline at end of file diff --git a/api/search.index/1e62ed4e08.js b/api/search.index/1e62ed4e08.js new file mode 100644 index 00000000..7edec517 --- /dev/null +++ b/api/search.index/1e62ed4e08.js @@ -0,0 +1 @@ +rn_("FQHBAAAXoBxwTRkVAcgAABCcIHMRHRODAuEAABRIY3MlgcAAABEtHAAVJR8bAuAAABrCZWwxxAAACBWsGK0YBQLAAAAUWyAkkB1jIG1uocUAAOkY+wJobzMBwAKBxwAAHQrqDQsAALYUAAAtGQAAQRkAAE8cAADSHAAA2xwAAGYdAACuHgAAqx8AAMAfAACWABAAAAAhwgAABRWlGyHBAACEFhYdAQAB4AAAHVlzOzAAAAEAACcAAwD2BwIAWxAAAHsUIwChyAAAOB0FAsAAABGRFzWzHLQc7R5kdCMAA8AAABmexgAAHLViaXODchwAADHMAAD9HhkfXCCzgf2MAQDEAAAYvP86/Xg4B8EAAAz4yQAAG4E=") \ No newline at end of file diff --git a/api/search.index/1e8c195075.js b/api/search.index/1e8c195075.js new file mode 100644 index 00000000..13582eb3 --- /dev/null +++ b/api/search.index/1e8c195075.js @@ -0,0 +1 @@ +rn_("BQPAAAAxKA0nEigSMBANORA6EBEbH2ZnaSHHAADdGZUdNQHAAAAWAR939xn4GV8cMcAAACsNLA01EPsCZHIlhsUAAKRtHxlvHwcfOh/7AmNl+wJpeQWKwQAAop8YpWQb4cMAAG0WAUGIzBEAACHHAADKHo0gMc4AALoesCC1INcFAf0V4QDMAAAgt8MAABz88zEJAAB6EfwBAUDh4wwAANQRAADeMcAAACUSJhKNFyMEAsMAABuAb3I7MAAAAQAAVwIGAGUQAQBVEXYA3BQAACQV7wC2G3YAqB12ANcDAP5ADwgIwAAADQrNAAAexgHZGQAA") \ No newline at end of file diff --git a/api/search.index/1ec9a11230.js b/api/search.index/1ec9a11230.js new file mode 100644 index 00000000..48708974 --- /dev/null +++ b/api/search.index/1ec9a11230.js @@ -0,0 +1 @@ +rn_("AQACwgAAH37FAAAcXmx07NMXAAC0GwAAbhwAAJIcAACqHAAA7BwAACQeAABgHgAAaR8AAHgfAACgHwAAZCAAAKUgAAAAACIAAAAA8wABc+GZGwAAvR4AABA=") \ No newline at end of file diff --git a/api/search.index/1edea5d5ed.js b/api/search.index/1edea5d5ed.js new file mode 100644 index 00000000..24a6e5e3 --- /dev/null +++ b/api/search.index/1edea5d5ed.js @@ -0,0 +1 @@ +rn_("BQLAAAATrxoYrh9jZQUCwAAAFnMfpPMZbnIFAsAAABeBHyqvHjQfYWinBAD9HUECwQAAFNLDAAAe88EAABeSwgAAGZnjeRAAAAkZAABAHwAAkx8AABMA") \ No newline at end of file diff --git a/api/search.index/1f58bdc733.js b/api/search.index/1f58bdc733.js new file mode 100644 index 00000000..f2453e48 --- /dev/null +++ b/api/search.index/1f58bdc733.js @@ -0,0 +1 @@ +rn_("BQLAAAAUjBkm3RmVHWVzNQHAAAAWAR939xn4GV8cMcAAACsNLA01EPsCZHIlhsUAAKRtHxlvHwcfOh8zAAPCAAAR/2NlbwH2AwAAWwPDAAAcYWl1eQ==") \ No newline at end of file diff --git a/api/search.index/1fde492e85.js b/api/search.index/1fde492e85.js new file mode 100644 index 00000000..7dd5558e --- /dev/null +++ b/api/search.index/1fde492e85.js @@ -0,0 +1 @@ +rn_("AULirhcAAH8cAACAHQAAUAAbhsEAAB6NIcIAALoYDR9bA8gAACBRYW9yBQPAAAATqhoWZx8TqxphbXIViMYAAB+yIMseNwIA/RFBDcYAACCCwwAAGqzGAAAcfsMAAB8P+3YQAACSCJgCzQIEAFIASwA0ABgAIwEkAA==") \ No newline at end of file diff --git a/api/search.index/1ffe0d0632.js b/api/search.index/1ffe0d0632.js new file mode 100644 index 00000000..7e807073 --- /dev/null +++ b/api/search.index/1ffe0d0632.js @@ -0,0 +1 @@ +rn_("AUDmAg0AAIMYAAB+HgAAyh4AAAgfAACNIAAAmCAAABAwAADBxAAAYx6hxQAAlhkHFgP9NaEDxwAAIKbDAAAfCsMAAB+fxQAAIGnCAAAaucEAABIN/yALAAA8CTEEAQATAgEADALgAXsAUAALABUAEQDoAEUA") \ No newline at end of file diff --git a/api/search.index/20415c610b.js b/api/search.index/20415c610b.js new file mode 100644 index 00000000..a4726804 --- /dev/null +++ b/api/search.index/20415c610b.js @@ -0,0 +1 @@ +rn_("UcAAAGgUnBsOHTgdOR3zAgFz4acQAACBFgAAciUCwwAAFrYeMDEcWB2aHXJzABJQHwUCwAAAGpEgGKgfbG3zAANvcnk7MAAAAQAAuQABAGQCuQA=") \ No newline at end of file diff --git a/api/search.index/20a24b7b1c.js b/api/search.index/20a24b7b1c.js new file mode 100644 index 00000000..0ee96ba8 --- /dev/null +++ b/api/search.index/20a24b7b1c.js @@ -0,0 +1 @@ +rn_("FQHCAAAd1B9y7xxlAsAAABHcFxSbGmFjPxBAEKYZTR4tH0cf8wABZAE1HwAA+4YrAsQAABtBbXQFAsEAACYjHRMfw5UeaGzzAIPyRhAAABkN") \ No newline at end of file diff --git a/api/search.index/20b42845b4.js b/api/search.index/20b42845b4.js new file mode 100644 index 00000000..eb3a67bd --- /dev/null +++ b/api/search.index/20b42845b4.js @@ -0,0 +1 @@ +rn_("odAAAIAgVQLAAABIixuMG4MchBwQSR1lc28QcBBxEHIQdRgFAsAAABmMHhL2FHN0BYnAAAAaIB4b/x6nEgD+QFHAC8AAABBKxgAAHNDIAAAdosQAABmtxAAAGpw7MAAAAQAAgQAEAGIKegDhCgEALwsAAMMLAwA=") \ No newline at end of file diff --git a/api/search.index/215c07b183.js b/api/search.index/215c07b183.js new file mode 100644 index 00000000..606e12dd --- /dev/null +++ b/api/search.index/215c07b183.js @@ -0,0 +1 @@ +rn_("AQEDwAAAH6bAAAAUrccAAB1cZWl3OjAAAAEAAAAAABIAEAAAADcLOAv3FPgU5RntGY8bdRyHHH0dhR2LHVgeXx6tHrYeAB82H2Eg") \ No newline at end of file diff --git a/api/search.index/2166be8459.js b/api/search.index/2166be8459.js new file mode 100644 index 00000000..c583b17f --- /dev/null +++ b/api/search.index/2166be8459.js @@ -0,0 +1 @@ +rn_("ocgAAMQcAQDCwADIAAAf08EAAB5cxQAAHmfvsA0AALAYAADoGAAAgxkAAOgZAAB6GwAAohwAAGcdAACHHgAA7B4AAIQfAACYHwAAuR8AAL4fAAB2IAAAeCAAADAIARUQACAAQwADwAAAFoDIAAAcn2Fmc+JCGwAAqRsAAEodAAAzMHUBwgAAxS8ebn0WfhZ/Fu4Y7xjwGPEYrwHAHsDQAAAgXMAAAB5uxgAAHaM=") \ No newline at end of file diff --git a/api/search.index/2186ffe1ab.js b/api/search.index/2186ffe1ab.js new file mode 100644 index 00000000..779485b0 --- /dev/null +++ b/api/search.index/2186ffe1ab.js @@ -0,0 +1 @@ +rn_("BQLBAAAUWyAkkB1jIG1u8wUBY/JBGQAAbQUFAsAAABE+HhbAH2V0GwLDAAAdj2V0E4QCyAAAIFhzdCsCxwAAHj1ucxsCwwAAIJNldCHGAADIHmIfFwYA/gBDIgnRAAAfpsMAAB+eyAAAHyLLAAAfJOQkCQAA8QoAAHkdAACFHwAAnSAAADEAAA==") \ No newline at end of file diff --git a/api/search.index/21c2b07050.js b/api/search.index/21c2b07050.js new file mode 100644 index 00000000..c89debaa --- /dev/null +++ b/api/search.index/21c2b07050.js @@ -0,0 +1 @@ +rn_("ocUAAE8cBQLAAAARLxkWSB1ycwUCwAAAExYdExcdbHAbAsQAAByyZm4jAAPDAAAarcgAAB/OYmVu4SUSAACNFwAAEIcDAP4ACpAMwgAAGazJAAAfGsMAABuAOzAAAAEAAFgCBwBlEAEAVRF2ANwUAAAkFe8AChoAALYbdgCoHXYA") \ No newline at end of file diff --git a/api/search.index/22302971c6.js b/api/search.index/22302971c6.js new file mode 100644 index 00000000..3481054a --- /dev/null +++ b/api/search.index/22302971c6.js @@ -0,0 +1 @@ +rn_("RQHCAAAUDh10dRl2GVEcUhwVAsEAACfqHFIeF+scZGWgDROBAsAAAA2hZXIFAsAAABINHRF3HGl0BQLAAAAQ8R4jeByCHXJ2U4IDyAAAH6NlZnQjAP5ABggIwAAADRHLAAAeIMEAAB11zgAAH3L0swgAAI8JwwKgBvvAAMQ=") \ No newline at end of file diff --git a/api/search.index/223cb5cd12.js b/api/search.index/223cb5cd12.js new file mode 100644 index 00000000..44fcd92a --- /dev/null +++ b/api/search.index/223cb5cd12.js @@ -0,0 +1 @@ +rn_("BQLAAABB3xhnGR4dtx4ZYiBsbSsC4AAAHDdzdSHEAAB9HQAfBQLBAAARTxmlghtycwUCwAAAEwYdYkQQRRDLE8wZSBttG2NzHwf+QABuA8gAAB2AzwAAIFvGAAAdPsEAABii") \ No newline at end of file diff --git a/api/search.index/2267e51ff5.js b/api/search.index/2267e51ff5.js new file mode 100644 index 00000000..25179ee2 --- /dev/null +++ b/api/search.index/2267e51ff5.js @@ -0,0 +1 @@ +rn_("FQHDAACkbxxizxSxygAAdh0FwMOAYAAAErMeFXkcJXocmx0FAsAAABINHRF3HGl0BQLAAAAQ8R4jeByCHXJ2U4MDyAAAH6NlZnQbAsYAAB0ObnQxxQAA/R4ZH1wg8wEDYmlz41ULAAAFHQAANx4AAFwfAAAQEAUCwAAAH2IgGDodZXAFAsAAAKY3HhjDHmNkMcMAAH4XfxcvHQX9wQGABQAAHawfGhMfHq0fFTEc+wJ1eVcPAP0aSQfPAAAggsMAABeAxAAAGKkB7ggAAA==") \ No newline at end of file diff --git a/api/search.index/23096a4f79.js b/api/search.index/23096a4f79.js new file mode 100644 index 00000000..b1fa84b7 --- /dev/null +++ b/api/search.index/23096a4f79.js @@ -0,0 +1 @@ +rn_("ocMAAE4WFYTGAAAUjB9XHSuDwQAAFmsFkcAAAOJlFqRLGQUCwAAAUMEQTBFNEU4RTxEjnBlWG2RuJwcA/gBIiwrCAAAZEsIAABuXwAAAGZnEAAAdWTowAAABAAAAAAAdABAAAAAeCR8JLwntCu4KYQ1EEEUQoRDDEMsTUBRRFFIUUxSLF8wZ9hmqGkgbbRt+G3IdJR4mHiceKB5KHucejCA=") \ No newline at end of file diff --git a/api/search.index/23bfe8113f.js b/api/search.index/23bfe8113f.js new file mode 100644 index 00000000..1dbb3b7b --- /dev/null +++ b/api/search.index/23bfe8113f.js @@ -0,0 +1 @@ +rn_("YcMAAGkQahD2EfcR+BH5ESWExQAAFJ4feR2dICWExAAAFGMfCB2UIPOBAmxyUcMAALsavBpjHIMgiCDzggJydBUBxgAAEKsecyAdEwICwQAAG29wdeHKHAAApR4AAEI1AcAAABfGH2RoENMZBB/zgoYxxQAABhWCF4MXfwH9JYEAwgAAEfU=") \ No newline at end of file diff --git a/api/search.index/23ec2da56c.js b/api/search.index/23ec2da56c.js new file mode 100644 index 00000000..5b37ece5 --- /dev/null +++ b/api/search.index/23ec2da56c.js @@ -0,0 +1 @@ +rn_("McEAACoNUxbXHjWCwQAAtIscpRe6HHYfIcAAAFceUCBlAcAAABYdHXf7FPwU/RT7GfwZ+xzzgQNlc3U7A8IAAB7cYW9yocMAAE4WIwECwAAAEMFpdOgeCQAAUBQAAIsXAAB+GwAAch0AACUeAABKHgAA5x4AAIwgAAATAAMAABUBwgAAEa8ZdCkNBQLBAAAehB8fsx9sc2UBwQAAwB0Vc/EM8gzzDNMU1BSTHWsDwAAAF7Rub3MFjMAAABu+HxTbHAUDwAAAGCEfG7wfG70fbHR1BQLCAAAXux8UIB9zdBOBAswAACB4ZHTbBMkAAB9SYWNzdhUBwQAAFNocdlIWocMAAJkb+wNjZXT3AQD9EEUUzQAAH7/7CwsAAAEAVwIWDEcAcALOAEsAXACiAC0C") \ No newline at end of file diff --git a/api/search.index/23fa4ee052.js b/api/search.index/23fa4ee052.js new file mode 100644 index 00000000..45a2ce52 --- /dev/null +++ b/api/search.index/23fa4ee052.js @@ -0,0 +1 @@ +rn_("NQLCAABSGhlrG2UccyB/IBbQH2l3gx1rHpkfBf3AISAEAAAVBR8RBBUinBlWGxdvHwFA48EQAABMEQAAtBQAAEsbAAADFvvABIQ=") \ No newline at end of file diff --git a/api/search.index/241a7dee40.js b/api/search.index/241a7dee40.js new file mode 100644 index 00000000..0f24b76e --- /dev/null +++ b/api/search.index/241a7dee40.js @@ -0,0 +1 @@ +rn_("AQGBwQAAHv7t4hkAAJgcAAAMHQAAFR0AAB0dAACKHQAAYh4AAGceAAC+HgAA+x4AAAEfAAA3HwAAah8AANAfAAAAAAAAAQAS") \ No newline at end of file diff --git a/api/search.index/24a33bf16b.js b/api/search.index/24a33bf16b.js new file mode 100644 index 00000000..17cd2e9c --- /dev/null +++ b/api/search.index/24a33bf16b.js @@ -0,0 +1 @@ +rn_("9QLBAAAz5xnpGXYcNIgYiRj3HnN2DQklgsAAABW0GxcSrR+hwQAAIhahxAAAbR8nBQEIZimvj0/GAAAdMcgAAB9vxAAAG7PFAAAdkzBlZmltb3N27f8MAABLDQAAuxAAABsZAABhGQAARhsAAIgcAADPHAAABx0AAC4dAAB/HQAATh4AAAcfAAA6HwAADxAwIAAAAAFB4UIbAACpGwAAM+sExgAAHUJjbm92") \ No newline at end of file diff --git a/api/search.index/24b43df8c9.js b/api/search.index/24b43df8c9.js new file mode 100644 index 00000000..cd9f21e9 --- /dev/null +++ b/api/search.index/24b43df8c9.js @@ -0,0 +1 @@ +rn_("BQLAAAATpSAQMR1vcxsCwgAAHz1jcvMAgoKGFgAAFQHCAAAd1B9y7xxlAsAAABHcFxSbGmFjPxBAEKYZTR4tH0cf8wABZAE1HwAA84GGwcUAAJUe+4MFg8EAABMIEhBXCzv9AUgIwwAAGcbOAAAfDcHKAACpGxUBxwAAEK0fc38dMcAAAEINQw10HUMCA8EAABIHwgAAFNthaXM7MAAAAQAAfgAGAO0XdwCTGAAAZRkBAD0eAACeHgEAWyAAAPuTIcYAADIcph1hxwAABhkHGTsdPB3KH8sfBQLAAAAUAhIhaxhRH2NuIcMAAA0ZsRsVhMIAACO4GqgfoRkTAALAAAAZLmR4AcEZAAABRjswAAABAAB3AAEA7Rd3AKHCAABPCvdfAP5AzWUBwwAAH4s7MAAAAQAA9gEGANoDAAB+BPkA/wgDAEMLAABGDvUAShsAAA==") \ No newline at end of file diff --git a/api/search.index/24cb213051.js b/api/search.index/24cb213051.js new file mode 100644 index 00000000..8ff14153 --- /dev/null +++ b/api/search.index/24cb213051.js @@ -0,0 +1 @@ +rn_("BQLCAAAZJx8amh9tdiMAA8QAAB9pwgAAHRNlaGkBVwoAAAUDwQAAFAYdE08ZEqIYYXB0FYTAAAAXgiBtHiMGAsMAAB8CZXPyDhoAAGcFCYnQAAAgZcYAABulBQLAAAAfcx8cCx9hdGHAAAAHCRsLkxCUEJUQ0BkFhsAAABb5HiReILEg+wJscgkC1gAAILbGAAAcqGR0KwLFAAAaDWZ1AQD9f+gDMoVXXNLgAAAbdMgAAB1wxgAAG2pY61RCwRW4iL5fzgAAH3QbncgAdswAAB8MSkChbpHLAAAe8AA0LPElPPeSHu3y6xEAAIYN") \ No newline at end of file diff --git a/api/search.index/24d02023f2.js b/api/search.index/24d02023f2.js new file mode 100644 index 00000000..cfe0b354 --- /dev/null +++ b/api/search.index/24d02023f2.js @@ -0,0 +1 @@ +rn_("BYPAAAASshoUixdFAsIAABV+GxqoH3B0BBIFEl0dXh0hwAAAbhCbHvsCcnSjAP0BiQjJAAAdmsUAABixxgAAH4P37AoAABwCcgsBAIsERgFeAA==") \ No newline at end of file diff --git a/api/search.index/24d5b9cb7d.js b/api/search.index/24d5b9cb7d.js new file mode 100644 index 00000000..100f42ae --- /dev/null +++ b/api/search.index/24d5b9cb7d.js @@ -0,0 +1 @@ +rn_("ocsAAF0dNQHAAAAaXB9y/R4ZH1wgoccAAAga+wJkZUHKAAB1GXYZURxSHKHDAAC0GEHGAACqHKscrBxkIAWMwAAAFx8eNGgZaRnyHhUBxAAAFlgecpQZZQHCAAAQhSBzFR8WHxcfGB9aIKQgBYnAAAAiuhgNHxlRIAUDwAAAE6oaFmcfE6saYW1yFYjGAAAfsiDLHjcCAP0RQQ3GAAAggsMAABqsxgAAHH7DAAAfD/t2EAAAkgiYAs0CBABSAEsANAAYACMBJAAFAsAAABH5GBBMHG93K4rDAAAZZxUCxgAAJFceUCAW/B5zd+QZFQHHAAAWYSByPx8TAgLFAAAdhWFyAfgYAABLwASE5gAAFhrBAAAXj6HDAAC4GCHDAAC3GAIfMcgAAH8fgB+uIDUBxAAAElYeY3ceeB6WIPMAAmls4aYbAAAkHQAAIPvBBCBByAAAfRxDH0QfpyChxAAAkhkFAsAAABORGRMqHWxzAQP+wE/EH1kONAXYCYK8jy3HAAAfR3H6UdHhIc8cTb3EAAAeiXoE5ipHOjSBZ47DAAAeOQU0+7y3OrmjbrjCAAAXkG9+iDH6O4Xzprtns4sZSO1FDQAAqhgAAK4ZAADRGQAACBoAAA4aAACkGgAAYhsAAGobAAC1GwAA6hwAACsdAABSHgAAdR8AAFAAEAAAAADBzQAAYx4BQDswAAABAACLAAMAQgsAALoNiQCAGwAA+wJkeAUCwAAAFK4bFTAfYWzxAP0UCAYAAQQbHPZFCwAAcwLjEQcABwD1AAFBOzAAAAEAAPYAAgA4EAAARBL1ACuUxwAAHyelAcAAABGJF3TEHBsCwgAAHEtkbhWIwQAAH7Igyx7lDwD+QEQsC8UAABjrAAEDBSMkJ8kAAB+aOzAAAAEAAAEBCwBjAQAAxAcAAPwIAAA5CQEARQn1AH8NAABfEAAADRIAAEscAQD2HAAA/x4AAA==") \ No newline at end of file diff --git a/api/search.index/24da7c3020.js b/api/search.index/24da7c3020.js new file mode 100644 index 00000000..b3f22f07 --- /dev/null +++ b/api/search.index/24da7c3020.js @@ -0,0 +1 @@ +rn_("BYnBAAASoCAUqCAjjQLGAAAgr3BzpQHEAACmah93jhwTgQLIAAAflm5yBQLEAAAapCAYmiBkcwUCxAAAHbcgF40gY2Ub/RABDMkAACB7zAAAIJnIAAAgbQUCwwAAFGwgFXogaW1DgcAGQM8AACC0zAAAIJ8FAsAAABTnHhp5IGVsBQLAAAAWlR8blyBidiuFyAAAIGsVAcAAABeMIGKZHvMCAXMBPx4AACsCxQAAH5RtdAUCwQAAFJYgGK4gaWwjhwLHAAAgp2RlAYQC0wAAILjHAAAglW9zV/aC/kBbqgnGAAAfWMcAAB+TxAAAHubMAAAgmAUCwAAAH4sgFPEcbHJXBQD9TFILxQAAHVvKAAAfxMwAACBuxAAAHPLMAAAgWeapFAAACxUAAM0ZAADqGQAAghsAAIsbAACDHAAAMAARECOBAsEAABibaXmhxQAAdxkBAwHJAAAgpWbhsBkAAEkfAACBsccAAFQd+wJoaQUCwAAAFI4gFFscZnIxwQAAWRZaFpEe8wL9AFAMOzAAAAEAACEACgCADRcA1RQAABwZAQDSGQAAWxsAAKAcAADgHgAAHx8AANQfAACjIAAAJQHBAAAVoBlwZA0THfuO") \ No newline at end of file diff --git a/api/search.index/24dc7756bb.js b/api/search.index/24dc7756bb.js new file mode 100644 index 00000000..b12a9c0c --- /dev/null +++ b/api/search.index/24dc7756bb.js @@ -0,0 +1 @@ +rn_("ocgAAGoZQcAAAFMKVApVCqQYAUbk/RgAAJkcAAD3HAAAGx0AAB8dAABBIQCXAQD9ECgGwgAAHGTCAAAckOi4GAAAeBwAAIscAACUHAAAgh0AAK8eAAAyHwAANB8AAGwfAAAQIAAAICsCwgAAFnhhdA==") \ No newline at end of file diff --git a/api/search.index/2515027d4f.js b/api/search.index/2515027d4f.js new file mode 100644 index 00000000..6c4cb167 --- /dev/null +++ b/api/search.index/2515027d4f.js @@ -0,0 +1 @@ +rn_("scwAAOkeMcMAAJkamhojHiMCAsYAAB+Fb3TmUBEAAIIZAAD+GQAAaxwAAIIeAADWHgAAmSAAAEAQEACxyQAAYR37A2Fpc6HFAAA3HlsDwgAAFEphb3Q=") \ No newline at end of file diff --git a/api/search.index/25e6c75d6c.js b/api/search.index/25e6c75d6c.js new file mode 100644 index 00000000..ed3f3830 --- /dev/null +++ b/api/search.index/25e6c75d6c.js @@ -0,0 +1 @@ +rn_("BYnBAAASoCAUqCAjjQLGAAAgr3BzpQHEAACmah93jhwTgQLIAAAflm5yBQLEAAAapCAYmiBkcwUCxAAAHbcgF40gY2Ub/RABDMkAACB7zAAAIJnIAAAgbQUCwwAAFGwgFXogaW1DgcAGQM8AACC0zAAAIJ8FAsAAABTnHhp5IGVsBQLAAAAWlR8blyBidiuFyAAAIGsVAcAAABeMIGKZHvMCAXMBPx4AACsCxQAAH5RtdAUCwQAAFJYgGK4gaWwjhwLHAAAgp2RlAYQC0wAAILjHAAAglW9zV/aC/kBbqgnGAAAfWMcAAB+TxAAAHubMAAAgmAUCwAAAH4sgFPEcbHJXBQD9TFILxQAAHVvKAAAfxMwAACBuxAAAHPLMAAAgWeapFAAACxUAAM0ZAADqGQAAghsAAIsbAACDHAAAMAARECOBAsEAABibaXkhwgAAoByjIPOCjBWBxAAAEfAcSx8FAsAAABHaHhE2HnJ0+4khwgAAaBsaHTMA/QABHMQAABnCwQAAFl8B0gsAAMHCAABKHQXAxABKAAAawh8X4B4UWh0BAP0IARLFAAAftsIAAB8bwgAAF7TFAAAfN+aiDQAAkRsAANQcAABVHgAAxB4AAO4eAADJHwAAMSAAABUBwAAAI0IZnR10JRn7AmRy5xAB/ZTxBsgAABzvyQAAIFbMAAAfJcUAABnBeZTYms3CAAAcSOL6CgAA3RQAAE8ZAAAQAMHCAABjHqHEAABiEPv9EIAD") \ No newline at end of file diff --git a/api/search.index/2636ea69be.js b/api/search.index/2636ea69be.js new file mode 100644 index 00000000..4f4c5023 --- /dev/null +++ b/api/search.index/2636ea69be.js @@ -0,0 +1 @@ +rn_("cccAAHobexuiHKMc7B52IHggsccAAHcbNQHDAAAWZCBzmx+pH54g8wQBZNKaHgAA6DcCA/0cMALKAAAfCskAAB8IyQAAHwmDPQ0AAKUBxgAAHHIgdNEXWwPMAAAewGFvcA==") \ No newline at end of file diff --git a/api/search.index/266ee3fc65.js b/api/search.index/266ee3fc65.js new file mode 100644 index 00000000..172d7b80 --- /dev/null +++ b/api/search.index/266ee3fc65.js @@ -0,0 +1 @@ +rn_("FQHCAAAWsB52bBkVhMQAABSMH1cdUwADxQAAHp1wdHXiWQoAAEwUAADmHgAAQwChxAAA9xQVAcMAABfUH3DdFCHGAABNHH4glwMA/kAQZAHMAAAeUsIAAB+FAdcHAAArg8EAABZr") \ No newline at end of file diff --git a/api/search.index/26d0899351.js b/api/search.index/26d0899351.js new file mode 100644 index 00000000..f1e62cda --- /dev/null +++ b/api/search.index/26d0899351.js @@ -0,0 +1 @@ +rn_("FfzADwAAABDTGBDUGBDVGBDWGMEYFfzA/wMAABDJGBDKGBDLGBDMGBDNGBDOGBDPGBDQGBDRGBDSGMAYBfzA/gMAABDAGBDBGBDCGBDDGBDEGBDFGBDGGBDHGBDIGHMA/P8D4AAAGMLgAAAYw+AAABjE4AAAGMXgAAAYxuAAABjH4AAAGMj2CgkAAAEAGQKlDQoAhwU=") \ No newline at end of file diff --git a/api/search.index/26eacc7076.js b/api/search.index/26eacc7076.js new file mode 100644 index 00000000..ad3370d8 --- /dev/null +++ b/api/search.index/26eacc7076.js @@ -0,0 +1 @@ +rn_("BQLAAABCcA1xDXINaR4RbxthcgUCwAAAROwc7RzuHKAfEZIYY2UTAP0QBlTIAAAf08EAAB5cxgAAHKfFAAAeZ8YAACCP77ANAACwGAAA6BgAAIMZAADoGQAAehsAAKIcAABnHQAAhx4AAOweAACEHwAAmB8AALkfAAC+HwAAdiAAAHggAAAwCAEVEAAgAAEAAcEAAB65a/iYGwAA+wBuAAEA7QHEABIAowAjhQLBAAAgiml5FQHGAAAQqx5zIB0xxgAAXBldGUMcQcIAAHUZdhlRHFIcBQLAAAAcIB4SdR1lbvsCbnQxwwAA/hRiHMcfFQHFAAAQQB9z7BkrAsQAABubbHYbmccAAByfxwAAHIIxxAAArx40H2wfEwACyQAAHmplZuenFwAAuRcAAOkYAAB7GQAAlRwAADMdAACMHQAAfB8AABEREBATAAPDAAAfMsYAABtGZGVuAXsfAAAhxAAAXBsuH6HGAADUHgUCwAAAEIAWFy8fdHYBAP7Ab3EFVscR9Z0PhqJ9gCvG/IxuygAAIIUS4scnPSkLdJj8JmC2NjZW60WiYh9y4SEyxgAAHoYXk9wkIyKoHbe8Y5J+El16m2IImzowAAABAAAAAABaABAAAACfBjYJNwklCyYLKAspCzALMgszCzQLOgs7C8oLywsQDSYNORA6EGcQRhFHEe4R7xHwEfER8hFeFF8UYBRhFGIU5BQKFTMWaxiaGBgZGhlWGVcZWBlZGVoZXBldGbsavBpCG0MbRBtFG2MbaxupG6obqxusGy4cMhxBHEMcYxxlHGwcyRzKHMsczBzNHM4cSh1LHUwdTR1zHaYdjR6lHqYepx6oHqkevx5RH34fcyB/IIMgiCCzIA==") \ No newline at end of file diff --git a/api/search.index/26ee41bac8.js b/api/search.index/26ee41bac8.js new file mode 100644 index 00000000..de750995 --- /dev/null +++ b/api/search.index/26ee41bac8.js @@ -0,0 +1 @@ +rn_("BQLAAAAVoxoXzB9ldAWKwAAAE/IYEuYXIcIAAOUXYBwFAsAAAMLuGBejHWR0W8AAxMsAAB95UcYAAKEaohqOHlkftyCnCgD9mDMCxwAAIH3CAAAX5MgAAB2kxQAAILLjXgsAAKgZAABtHQAAtB8AACIA") \ No newline at end of file diff --git a/api/search.index/27dab8cb17.js b/api/search.index/27dab8cb17.js new file mode 100644 index 00000000..42535e98 --- /dev/null +++ b/api/search.index/27dab8cb17.js @@ -0,0 +1 @@ +rn_("scQAAIsc8wGCOjAAAAEAAAAAABMAEAAAAAYLBwsIC1QUARWlF3oZOBx6HLoc2BwPHX4djx2bHT4fdh+sH28gkyA=") \ No newline at end of file diff --git a/api/search.index/29afdaa764.js b/api/search.index/29afdaa764.js new file mode 100644 index 00000000..e0b0887c --- /dev/null +++ b/api/search.index/29afdaa764.js @@ -0,0 +1 @@ +rn_("BcjAggAAABNDGRhAHqdFHwmDwQAAGJzQAAAghSHAAAAgDaYNJwMB/gBDIgnMAAAfWcgAAB6bzAAAH5fOAAAgWgHTCwAAFQHBAAAXoBxwTRkxxAAACBWsGK0YBQLAAAAUWyAkkB1jIG1uocUAAOkY+wJobzMBwAKBxwAAHQrqDQsAALYUAAAtGQAAQRkAAE8cAADSHAAA2xwAAGYdAACuHgAAqx8AAMAfAACWABAAAAD7Am10JYTCAAAUYx8IHZQgocEAAJ0bOwPKAAAeWmFjciHCAAB/DfYcMcYAAP4UYhzHH0UCwAAAFmEeFmIebXfIGckZlBvXHBUBxAAAM5kamhojHnSCGfsCY3L7A2VpbjWCwgAAEl4QIAmlHKYcpQHAAAAYkSByrAYFhsAAABVpHxMTHUHBAAAHCZMQlBCVEFMAA8UAAB0GZXJz8usRAACGDaHGAABlGfMA/gBHEgz61wMAAJsALQljBDALXgA4AWQANgABAQ==") \ No newline at end of file diff --git a/api/search.index/29fbf027cf.js b/api/search.index/29fbf027cf.js new file mode 100644 index 00000000..800a12a1 --- /dev/null +++ b/api/search.index/29fbf027cf.js @@ -0,0 +1 @@ +rn_("tQHAAAAlehlvIHOaDQUCwAAAE68aGK4fY2UFAsAAABeBHyqvHjQfYWgnBAD9HUECwQAAFNLDAAAe88YAAB9zwQAAF5LCAAAZmeN5EAAACRkAAEAfAACTHwAAEwAlAcAAAFC/HMAcwRzCHHsgczsTaSDDAP1AEQLDAAAblsEAAB5q6AYNAAA0FgAAIRkAAEAZAADsGQAAcBsAAGAfAACWHwAAWSAAABQwAwAA+wJlebHEAACaDQEAA8cAAB8vxgAAG5vDAAAddWFmaTowAAABAAAAAAAjABAAAAA2CTcJJQsmCxANJg05EDoQZxDuEe8R8BHxEfIRGhlWGVcZWBlZGVoZXBldGbsavBpjG2sbMhxDHGMcZRymHY0ecyB/IIMgiCAxwQAAKA0nEigSOwPAAAAfG2ZnaQFH4dwcAAA5HwAAsPMChOE1CwAAzQsAAAEbAsAAABBGYXAhxwAA3RmVHTUBwAAAFgEfd/cZ+BlfHDHAAAArDSwNNRD7AmRyJYbFAACkbR8Zbx8HHzof+wJjZfsCaXmhygAAmRwFisEAAKKfGKVkG+HDAABtFgFBiMwRAAChxQAASRwhxwAAyh6NIDHOAAC6HrAgtSDXCwD9NeEEzAAAILfDAAAc/MUAAB9b6zEJAABIFgAAmxgAABMZAACrGgAApxwAALEcAACEHQAA9R4AAH8fAACVHwAAriAAAAIDAAABAPsCc3ahyQAAghtBwAAApR+1H4YgkiDzAAJlcuJhCgAA4wwAANQRAAAN4DHAAAAlEiYSjRcjAwLDAAAbgG9yOzAAAAEAAFcCBgBlEAEAVRF2ANwUAAAkFe8Atht2AKgddgAbAsAAAB6wbG7XXQD+QB88CsAAAA0KwwAADRHEAAAVBc0AAB7GAdkZAAA=") \ No newline at end of file diff --git a/api/search.index/2ae9904447.js b/api/search.index/2ae9904447.js new file mode 100644 index 00000000..0b2ffae2 --- /dev/null +++ b/api/search.index/2ae9904447.js @@ -0,0 +1 @@ +rn_("FQHFAAAWrR5yhx8VAcMAABZ9HXIcH/ODAmJzFQHEAAAWdRxyNB7BxwAABBr7AnBx+wJvchUBxgAAEFofc2obBQLAAABGfRxDH0QfpyATUR1zdAUCwAAAEi0NIEgQvBhkbjHAAABbDUYcRxwBRDswAAABAAD2AAMA1xgAAMYaegDVH3oA84GD9wQA/RHBDcYAABTVwgAAHDfFAAAczwFnAQAA") \ No newline at end of file diff --git a/api/search.index/2b0caa2aca.js b/api/search.index/2b0caa2aca.js new file mode 100644 index 00000000..99ca3565 --- /dev/null +++ b/api/search.index/2b0caa2aca.js @@ -0,0 +1 @@ +rn_("BQLAAAATgRgX0xlpcgECAsAAABzwxQAAHwBhcuY/CwAAZRQAAPAZAABLHwAAUx8AAH0fAACoIAAAIBBAACHJAABrG3MgcccAAHobexuiHKMc7B52IHggwcMAAAQasccAAHcbNQHDAAAWZCBzmx+pH54g8wQBZNKaHgAA6LcEAv0cOALKAAAfCskAAB8IyQAAHwmDPQ0AAOcBAP4ABCYKxAAAHQrKAAAfB+TvAwAAugYAACoNAABTFgAA1x4AAAgAAA==") \ No newline at end of file diff --git a/api/search.index/2c3bfa5997.js b/api/search.index/2c3bfa5997.js new file mode 100644 index 00000000..c491b7e8 --- /dev/null +++ b/api/search.index/2c3bfa5997.js @@ -0,0 +1 @@ +rn_("BYnBAAASoCAUqCAjjQLGAAAgr3BzpQHEAACmah93jhwTgQLIAAAflm5yBQLEAAAapCAYmiBkcwUCxAAAHbcgF40gY2Ub/RABDMkAACB7zAAAIJnIAAAgbQUCwwAAFGwgFXogaW1DgcAGQM8AACC0zAAAIJ8FAsAAABTnHhp5IGVsBQLAAAAWlR8blyBidiuFyAAAIGsVAcAAABeMIGKZHvMCAXMBPx4AACsCxQAAH5RtdAUCwQAAFJYgGK4gaWwjhwLHAAAgp2RlAYQC0wAAILjHAAAglW9zV/aC/kBbqgnGAAAfWMcAAB+TxAAAHubMAAAgmAUCwAAAH4sgFPEcbHJXBQD9TFILxQAAHVvKAAAfxMwAACBuxAAAHPLMAAAgWeapFAAACxUAAM0ZAADqGQAAghsAAIsbAACDHAAAMAARECsCwQAAGJtpeQ==") \ No newline at end of file diff --git a/api/search.index/2c967c6b0f.js b/api/search.index/2c967c6b0f.js new file mode 100644 index 00000000..d20bca5b --- /dev/null +++ b/api/search.index/2c967c6b0f.js @@ -0,0 +1 @@ +rn_("ocQAALQQIcAAAGUYqRoFhcAAABexHheyHiHBAAC9GBEf8wEDMm1z8+kLAAAoBsMFBQLBAAAWDB9Wfh5/HoAegR6YIG5w+wJwcgUCwAAAEHccIuMX2B5seQUCwAAAEQ0dIMYTMx5jZAUCwwAAJ14gsSAepB9kc2UDxQAAGKYgFmkgxGMeYWZwjRiOGAkfZB+KH3Ig+wJwcrcBAP0QAQ3NAAAecDowAAABAAAAAAAeABAAAADlA5YGlwYdC1gLWQtaC1sLXAtdC2UNZg1nDV4Q4xRQFnYWqReqF6sXrxguGe4Z7xmIG40bjhuFHIYcJh+bIKHNAADUHgkC0AAAIGLIAAAdQmx0+wJhckv9AQFMwQAAEFnGAAAZq9MAACBwyQAAHIFBxgAASBZJFkoWhB0FAsAAAB0iHhEwHW5yFYHDAAAU3x6XHBUBwQAAFG8ZZ8MZKwLGAAAZ2XJz2/0RQQHHAAAZBc0AAB60AUXhcBgAAC8eAABDBcDAAMYAABWoGBiAHBNvGC/JHnUgwcQAAG8UK4PSAAAgsyHDAAB4HIIdIcYAAIMYCB+1AsMAAKQCGRQEGWJxjwu7wQIo4AAAHvFFAcgAABh9IGSoGakZqhltHRUByAAAEJwgcxEdE4MC4QAAFEhjcwUCwAAAEZEXNbMctBztHmR0IwADwAAAGZ7GAAActWJpc4NyHAAAE4ECxAAAGLxkaPOBAmRzawPIAAAZmGVsb6HGAABGFkHMAAA/HUAdQR13HwEA/sB9+BM1zXIPXihSEqz+PVQmPEgMW3Qv7MgAAB+jwwAAEh9/8Q0Ndghs+Fz1Lx86XqDFAAAQeB9JYCZ0wwAAHwtAZDCBSEpvzslEbdwccT5+yZaHf+xWAQAAXwIAAOUHAACeCAAAIQkAAN4UAAB9FgAA1hcAAO4YAAChHAAAJh0AAPYeAACJIAAAEBMAIDAAAA==") \ No newline at end of file diff --git a/api/search.index/2ccf2b558f.js b/api/search.index/2ccf2b558f.js new file mode 100644 index 00000000..980cc3be --- /dev/null +++ b/api/search.index/2ccf2b558f.js @@ -0,0 +1 @@ +rn_("BQLFAAASvx6nZR9pcoME/QgJAsoAAB+nwwAAHKHGAAAeaeUWFQAAZhsAAGEdAAB6HQAAIB8AAL8fAAAhIAAFAsAAACKBHcIeOLoesCC1IGxyocQAACkecdAAAKkgqiCrIKwgrSC2ILgg8wMCc3YBpiAAAKHDAACdG2UBwwAAIHwdaB5zExUUFRUVZB1lHVkeYcYAAD8dQB1BHTMfdx9wIPsCaG8FhcAAADLBHsgfXyAV7BghxQAAOx9SIHcuAP03yAbFAAAfec0AAB8EO2/JjcvqxQcAAG4ZAAB7HQAAyR4AABAfAAChHwAArx8AAMEfAAB1IAAAhCAAALQgAAAwAAAwAAA=") \ No newline at end of file diff --git a/api/search.index/2cde24bcef.js b/api/search.index/2cde24bcef.js new file mode 100644 index 00000000..ac34686a --- /dev/null +++ b/api/search.index/2cde24bcef.js @@ -0,0 +1 @@ +rn_("FQHCAAAxox04H28fbSgdBQLAAAATvhgdpB90dSHHAABeILEg+wJkcxOBAsAAABZFbG2h0wAAgCAjAIXAAAAUbgFkFgAAFQHAAAASGx9u2RkFAsIAABKxHBAaH2l5NQHAAAAV6xxo+hH7EXkeW8AEBswAAB+gBYrAAAAVth8m6hxSHiHOAAAQH4Qg9xMA/oBGjBnBAAAbfMIAABe0xQAAHzc6MAAAAQAAAAAAIAAQAAAAzAugDaINow2kDaUNSRDlFP0Y/hj/GAAZARkNGpEbkhuZHJoc1BzVHNYc9xz4HPkcGx0cHR8dVR6xHrIexB7uHskf") \ No newline at end of file diff --git a/api/search.index/2cea6eb2be.js b/api/search.index/2cea6eb2be.js new file mode 100644 index 00000000..09ed603c --- /dev/null +++ b/api/search.index/2cea6eb2be.js @@ -0,0 +1 @@ +rn_("ocMAALQYBQLAAABFqhyrHKwcZCAcKB9leQWMwAAAFx8eNGgZaRnyHhUBxAAAFlgecpQZZQHCAAAQhSBzFR8WHxcfGB9aIKQgBYnAAAAiuhgNHxlRIAUDwAAAE6oaFmcfE6saYW1yFYjGAAAfsiDLHjcCAP0RQQ3GAAAggsMAABqsxgAAHH7DAAAfD/t2EAAAkgiYAs0CBABSAEsANAAYACMBJAAFAsAAABH5GBBMHG93K4rDAAAZZxUCxgAAJFceUCAW/B5zd+QZFQHHAAAWYSByPx8TAgLFAAAdhWFyAfgYAABLwASE5gAAFhrBAAAXj6HDAAC4GCHDAAC3GAIfMcgAAH8fgB+uIDUBxAAAElYeY3ceeB6WIPMAAmls4aYbAAAkHQAAIPvBBCBByAAAfRxDH0QfpyChxAAAkhkFAsAAABORGRMqHWxzAQP+wE/EH1kONAXYCYK8jy3HAAAfR3H6UdHhIc8cTb3EAAAeiXoE5ipHOjSBZ47DAAAeOQU0+7y3OrmjbrjCAAAXkG9+iDH6O4Xzprtns4sZSO1FDQAAqhgAAK4ZAADRGQAACBoAAA4aAACkGgAAYhsAAGobAAC1GwAA6hwAACsdAABSHgAAdR8AAFAAEAAAAADBzQAAYx4BQDswAAABAACLAAMAQgsAALoNiQCAGwAAW8UEAMMAABikBQLAAAATrhsb8R5udBsCxAAAHzBhbFUHAP08CAYAzAAAHvICzAAAHvMFHB32RQsAAHMC4xEHAAcA9QA=") \ No newline at end of file diff --git a/api/search.index/2cfa2b6780.js b/api/search.index/2cfa2b6780.js new file mode 100644 index 00000000..25606744 --- /dev/null +++ b/api/search.index/2cfa2b6780.js @@ -0,0 +1 @@ +rn_("BQLDAAAXdR8YTx9kbRUBwgAAdKkgqiCrIKwgrSC2ILggduIMIcIAALMbWxxzAP0hQALgAAAghTowAAABAAAAAAA4ABAAAADRA9ID0wPUA9UD1gNADUIQQxCvFLAUsRSyFLMUxRTGFMcUyBTJFBIVlBgOGQ8ZEBkRGRkZLxmdGawZzhnCGm8bPRw+HD8cQByvHLAc6RxyHnMedB51HoQehR4VHxYfFx8YH5QfzB9aIGcgbSCVIJogpCA=") \ No newline at end of file diff --git a/api/search.index/2d93b93c9c.js b/api/search.index/2d93b93c9c.js new file mode 100644 index 00000000..47770578 --- /dev/null +++ b/api/search.index/2d93b93c9c.js @@ -0,0 +1 @@ +rn_("VQHAAACgLB5zmRfRHtIeRR9GH6HFAAClHEX9wwOAAgAAJTsfUiAzwR7IH18gI4Edwh4VPB/JHhAfdSCEIDHEAAB9G6QciB/7AmN184ECYXDzgQJucKHHAAC0HiMC/TABBckAACBxwQAAFP/FAAAfNscAAB/D7BkNAACdDQAA7RgAAJ8ZAABkGwAAkBsAAIAcAAApHQAAhh8AAI4fAACHIAAAoiAAAK8gAAAxABAABAAAAYICZimvj0/gAAASFzBzI4GG4AAAC8D7iw==") \ No newline at end of file diff --git a/api/search.index/2d9f7e44b4.js b/api/search.index/2d9f7e44b4.js new file mode 100644 index 00000000..f8235c8e --- /dev/null +++ b/api/search.index/2d9f7e44b4.js @@ -0,0 +1 @@ +rn_("BQLAAAAbGB00vhdfGWAZYnIBAgHGAAAbm2biJg0AAO4RAABWGQAABEABQuNyHgAAhB4AAJUgAACaIAAAMQAjgpTDAAAdJvuGFYTHAAAUjB9XHSMDAsMAABtWaXIBwxAAAMX9wlIBAgAAFXscFTAdFm4eE+MZF8ceFRbzAANpanLzqAYAAI8J0AQFAsQAABYxHxcuHWNkBYnBAAASoCAUqCAjjQLGAAAgr3BzpQHEAACmah93jhwTgQLIAAAflm5yBQLEAAAapCAYmiBkcwUCxAAAHbcgF40gY2Ub/RABDMkAACB7zAAAIJnIAAAgbQUCwwAAFGwgFXogaW1DgcAGQM8AACC0zAAAIJ8FAsAAABTnHhp5IGVsBQLAAAAWlR8blyBidiuFyAAAIGsVAcAAABeMIGKZHvMCAXMBPx4AACsCxQAAH5RtdAUCwQAAFJYgGK4gaWwjhwLHAAAgp2RlAYQC0wAAILjHAAAglW9zV/aC/kBbqgnGAAAfWMcAAB+TxAAAHubMAAAgmAUCwAAAH4sgFPEcbHJXBQD9TFILxQAAHVvKAAAfxMwAACBuxAAAHPLMAAAgWeapFAAACxUAAM0ZAADqGQAAghsAAIsbAACDHAAAMAARECOBAsEAABibaXmhxQAAdxkBAwHJAAAgpWbhsBkAAEkfAACBsccAAFQd+wJoaQUCwAAAFI4gFFscZnIxwQAAWRZaFpEe8wL9AFAMOzAAAAEAACEACgCADRcA1RQAABwZAQDSGQAAWxsAAKAcAADgHgAAHx8AANQfAACjIAAA+4wbAsAAABmldHoBBQLIAAAf08UAAB5nanflsBgAAOgYAACDGQAAZx0AALkfAAC+HwAAAIUgJYTEAAAXBR9EHpweBQLAAAASpxwy2BmpHAYfaXYFAsEAAKebHEedHJ4cgCCBIHJzIcQAAG4ceB+3AwD9FCAHxwAAIJCDkwgAAL8D/RBBTsMAAB88wwAAHac=") \ No newline at end of file diff --git a/api/search.index/2dc7270161.js b/api/search.index/2dc7270161.js new file mode 100644 index 00000000..e642e5e0 --- /dev/null +++ b/api/search.index/2dc7270161.js @@ -0,0 +1 @@ +rn_("FQLEAAAVaB6jnRtjbHsdEwACzAAAH6FhY+H0DAAAdg0AAAQVgsUAABQuHkUe8wCC4gwJAAC7HAAAeiAAAAMA8wACc3eD4QcAAA==") \ No newline at end of file diff --git a/api/search.index/2e58e98885.js b/api/search.index/2e58e98885.js new file mode 100644 index 00000000..b13770ac --- /dev/null +++ b/api/search.index/2e58e98885.js @@ -0,0 +1 @@ +rn_("xQHDAAAXfCBzQx0TAwLGAAAe+mly4p0aAAClGgAAXRsAADMwJcDDgkgAABabIBNsHheiIBZ9H24bRx1Tgv0SSADHAAAexccAAB8k") \ No newline at end of file diff --git a/api/search.index/2e81d284ef.js b/api/search.index/2e81d284ef.js new file mode 100644 index 00000000..819507cf --- /dev/null +++ b/api/search.index/2e81d284ef.js @@ -0,0 +1 @@ +rn_("IcMAAGEQSBEFAsEAABR8GxV6H2VmBQLAAAASkhgXjyBleRUBxwAAEEAfc+wZwwDADkDDAAAfLsoAACCF85oYAADSAz0C+wJnafsCbnQ=") \ No newline at end of file diff --git a/api/search.index/2e8f6471ba.js b/api/search.index/2e8f6471ba.js new file mode 100644 index 00000000..1a79717f --- /dev/null +++ b/api/search.index/2e8f6471ba.js @@ -0,0 +1 @@ +rn_("BQLEAAASvx6nZR9pciHGAABuHHgfAQMBxQAAHwBy4/AZAABTHwAAfR8AAKggAAAUAKHBAACGFvvAAEoBAgHFAAAeX3Li2hgAAOEeAACgIAAAFAD7iSHEAABVGaweBQLBAAAavR6lmRttc/sCZWwFAsAAABSSHBOqH2NyFQLDAAAnFB1gIB2OIG1wIh+hxQAAhRz7Am9yFQHEAAAWrR5yhx8hwgAAshAkEvdUAP03oB3JAAAgpcUAABuTxQAAHIfQAAAgieqpGAAARRkAALAZAADEGQAAMRwAAF4cAABmHAAATx0AAFgdAACaHQAASR8AAAOBAAAAEAWJwAAAEtoeFEsfKwLBAAAdGml0wcIAAEodZQPBAAAWth8TGx8WNx9kaXeRG5Ib1BzVHNYc7h6zg/0QgQLGAAAfhgUCwAAAGC0dFfUZY2QzgQPDAAAetmFwcUHOAAClH7UfhiCSIAEGAcUAAB82cuSQGwAAjh8AAIcgAACiIAAAryAAAAQAAAUCxgAAEZEepEkfbXRrA8EAABTQbG9ysccAAIgcJQHBAAAXaB9zIh0iHvsCZnAFicAAABZpHhR0GTHDAACiFzIZbB4FAsEAABSLHRVeHnFz8wEBcuKhFwAAthwAAHkgAAADACvAHIDBAAAcoeAAABYzxgAAHzilAcAAAKDzEXNEDvsCZG6hxQAApRxF/cMDgAIAACU7H1IgM8EeyB9fICOBHcIeFTwfyR4QH3UghCAxxAAAfRukHIgf+wJjdfOFAmFwBQLAAAAedR8fdh9vcgUCwwAAFHwbFXofZWZBwQAA2RjRHEgfiR8zAf0QIQHIAAAcdsoAAB2A5k4LAAAdEgAA9BQAAEsWAAC9FwAA3BgAAMUeAAAxEAEAK8gEJOAAABh/4AAAGOHgAAAesxWEwgAAaJYdlx2YHVMgVCBVIPcMKwLJAAAfp2lyNQLEAAARMx4T2B5pdO4Z7xmbIBsCwQAAF4FudSv9AUAMyQAAHl3AAAAX1ssAAB6OFQHDAAAWfR1yHB8FAsUAABYzHxNZHmhvAQD+wF/3EWQqAtW8N65XiucwDIAd13V2rHmgEt1R4LYypzqxjT5p7lGBP/0QT1omdngCjgEk623ZQymp74MC4nnlc0qcUpNSDMwk+mdPyvwU1ij7DfKGyAAAG5Y6MAAAAQAAAAAAKAAQAAAA7AN5BnoG9QcrCSwJDw0AEgwSFhUXFRgVORa0GLUY4xjkGNQZ1RnWGWYbZxtuGy8cDR1HHWEdYh1jHW4dbx16HUMe6R7qHuseIB9QH6MfvR+/Hw==") \ No newline at end of file diff --git a/api/search.index/2f568eb3a8.js b/api/search.index/2f568eb3a8.js new file mode 100644 index 00000000..0dcbda29 --- /dev/null +++ b/api/search.index/2f568eb3a8.js @@ -0,0 +1 @@ +rn_("ocQAALQQoc0AANQeCQLQAAAgYsgAAB1CbHT7AmFyK/0AAUzGAAAZq9MAACBwyQAAHIFBxgAASBZJFkoWhB3BxQAAbxQhxwAAgxgIH0UByQAAGH0gZKgZqRmqGW0docYAAEYWQcwAAD8dQB1BHXcf9x0A/ZeAE84AAB60Ad4UAAA=") \ No newline at end of file diff --git a/api/search.index/2f928aedbd.js b/api/search.index/2f928aedbd.js new file mode 100644 index 00000000..aa423819 --- /dev/null +++ b/api/search.index/2f928aedbd.js @@ -0,0 +1 @@ +rn_("FYTHAAAUjB9XHSHEAACcGVYbowL+AEiBCMIAABkSwgAAG5fEAAAdWfJhDQAAYgOhyAAAtRoVAcEAABBwHnOkHSOGAsQAAB5CY2n7A2Vycw==") \ No newline at end of file diff --git a/api/search.index/3044d1ab39.js b/api/search.index/3044d1ab39.js new file mode 100644 index 00000000..583c1ecc --- /dev/null +++ b/api/search.index/3044d1ab39.js @@ -0,0 +1 @@ +rn_("BQPAAABCDB9zH3QfZSCjpRwl1RRbG2lscwUCwAAAFJoYE4sXbnIFAsAAABGDHyGyGqIdc3S7/RFIAcEAABkfygAAHvA=") \ No newline at end of file diff --git a/api/search.index/30491cfa3e.js b/api/search.index/30491cfa3e.js new file mode 100644 index 00000000..339acf1e --- /dev/null +++ b/api/search.index/30491cfa3e.js @@ -0,0 +1 @@ +rn_("FQHDAAAWYSByPx/zAAF04UINAAB0HQAAEEMAA8EAABIHwgAAFNthaXM7MAAAAQAAfgAGAO0XdwCTGAAAZRkBAD0eAACeHgEAWyAAAGHGAAAMHfweah9rH9Ef0h8hxAAAVx5QIPMDAnN36NYUAACsFwAA0BcAAOQZAACOHAAAiB0AAI4dAABhHgAA2R4AAAEAEQAAOwPjAAAUpGFkeQ==") \ No newline at end of file diff --git a/api/search.index/309cad9f29.js b/api/search.index/309cad9f29.js new file mode 100644 index 00000000..a744167a --- /dev/null +++ b/api/search.index/309cad9f29.js @@ -0,0 +1 @@ +rn_("AUI7MAAAAQAA9gACADgQAABEEvUA8wABbTswAAABAAD2AAIA/AgAAEUJ9QChxgAA+xhDAP0AFQnCAAAQRsIAABBHxAAAH43GAAAcTuzNBwAAmggAAGIQAADhFAAA+RQAAJ0XAAC3GAAAjBkAAN4ZAAADGgAAfBsAAK0bAAACHwAAghARAAAAAPsCcncFAsAAAEGlH7UfhiCSIFG7GrwaYxyDIIggZG4F/cMQkAIAABWdHRpWIBcfHxQ2HgUCwAAAFaMaF8wfZXQFisAAABPyGBLmFyHCAADlF2AcBQLAAADC7hgXox1kdFvAAMTLAAAfeVHGAAChGqIajh5ZH7cgpwoB/ZgzAscAACB9wgAAF+TIAAAdpMUAACCy414LAACoGQAAbR0AALQfAAAiACUBwAAAE/MYc0QNIBkFAsEAABZBHxjcHm52bwP9DbAGxQAAHivLAAAfi8IAAB+NzwAAIHfjAI/AAAAIAQFZAQAA") \ No newline at end of file diff --git a/api/search.index/30abc5ca89.js b/api/search.index/30abc5ca89.js new file mode 100644 index 00000000..bb53ed72 --- /dev/null +++ b/api/search.index/30abc5ca89.js @@ -0,0 +1 @@ +rn_("BQLAAAAT2BwfiiBhcksDxAAAHU7HAAAfHmVsdAUCwQAAGE0fGU4fY3AFAsEAABq5Hx12IGR0PwH+QAVACccAAB7bwwAAHFfLAAAfuhWExAAAE+sZhxgrA8cAABzJxAAAGS1pbXZbwATAywAAHoo=") \ No newline at end of file diff --git a/api/search.index/3148b66a49.js b/api/search.index/3148b66a49.js new file mode 100644 index 00000000..8efbfa6f --- /dev/null +++ b/api/search.index/3148b66a49.js @@ -0,0 +1 @@ +rn_("BQLAAAAVYB4TaxBldBUBwgAAI+ocUh5suA0FAsEAABtMHxodH2FtZYTAAAASEA2/FMAUwRRQGfwcEh8bAsMAAB+2cnOxxAAAdh1hwgAAJw38Ef0RFxmEG04c9wUA/RXBEsYAABtbzwAAILP3YAEAAI8JMAcBAE8HhgChAQ==") \ No newline at end of file diff --git a/api/search.index/3156e2142a.js b/api/search.index/3156e2142a.js new file mode 100644 index 00000000..829a695a --- /dev/null +++ b/api/search.index/3156e2142a.js @@ -0,0 +1 @@ +rn_("VQLDAACxwxqwJxlpc+wU7RTuFO8Uhh0FkcAAAOJlFqRLGcHBAABMETMAA8IAABZraW5y/C8JAAC+AQEAVgUBAFwAKgMBBioAtACeACUA+wJudQ==") \ No newline at end of file diff --git a/api/search.index/3193009388.js b/api/search.index/3193009388.js new file mode 100644 index 00000000..0a908eff --- /dev/null +++ b/api/search.index/3193009388.js @@ -0,0 +1 @@ +rn_("BQLNAAAWmyAXoiBjcAUCwAAAHGAfEqYYb3khwwAAuxdRIAUCwAAAGV8fFkweYXcrAscAAB6hYXQBggLQAAAgtcEAAB5LaXYVAcMAABWdH3D/HJOC/QCJCsUAAB6gxAAAHkrCAAAdAPsDYWV0BQLDAAAVSB4VSR5wc6HEAABRGQUCwQAAEXYbEaUYZmkJAtAAACCwwgAAHkdpdisCygAAIG9jchuGwQAAHGoFAsAAAB2PIBNyHW5yKwPBAAAcac0AACCHYWVsFQLDAAAVXh8Q/Rxwc2gcBQLDAAAWXSATXR9zdSOBAscAAB+cY3S3AYL9AYkKxwAAHvXDAAAc/gUCxQAAFfQeE0Yec3XzAf1IGQ6E5AsAABvAEKDIAAAeT8gAAB5a") \ No newline at end of file diff --git a/api/search.index/31df6784b1.js b/api/search.index/31df6784b1.js new file mode 100644 index 00000000..6a196650 --- /dev/null +++ b/api/search.index/31df6784b1.js @@ -0,0 +1 @@ +rn_("McAAAJcbkRyRHQEDhccAAB8vwwAAHXXlJQsAAFwZAABjGwAAMhwAAEMcAACmHQAAEQAA8wACcnP2Cw0AAIoMxwRXAAYADQA=") \ No newline at end of file diff --git a/api/search.index/32b74d3070.js b/api/search.index/32b74d3070.js new file mode 100644 index 00000000..09ca413c --- /dev/null +++ b/api/search.index/32b74d3070.js @@ -0,0 +1 @@ +rn_("FYLCAAAToxm/GSsCxQAAGaJleSUBwQAAsCoZc/AUhx0TgQLGAAAcSGFwBQLAAACoRhymHBlsbwUCwAAAqcQcErEQbnQFAsEAABJuFBNFFm1uBZTAAAAXshgWGxkFAsAAAByzHhV/GGFo5w8A/R2BA8QAABIG8xIZAAChAqgA") \ No newline at end of file diff --git a/api/search.index/332eeabb2c.js b/api/search.index/332eeabb2c.js new file mode 100644 index 00000000..89375d4f --- /dev/null +++ b/api/search.index/332eeabb2c.js @@ -0,0 +1 @@ +rn_("McMAAPMUixiMGBWBxgAAEv4eih0jBALEAAAdGWh38p8XAABbBXUBwQAAop0XchgLGQtoGWkZARoCGvIelwEA/TABDsUAAB8lwQAAEEfBAAAVEuMqCwAAwQsAAJ8cAACyIAAAQQA=") \ No newline at end of file diff --git a/api/search.index/3420154a8e.js b/api/search.index/3420154a8e.js new file mode 100644 index 00000000..a59a5f3f --- /dev/null +++ b/api/search.index/3420154a8e.js @@ -0,0 +1 @@ +rn_("FQHCAAASfhhsbw0VAcMAABOEG2SVFwUCxQAAFIYdFIcdb3QVhMIAAKFEHJQXNQHAAAAQcB5zOxY8FqQd+wJkdAFE43AZAAC9GgAAnxsAAEIeAAAUABsCwwAAGXJsb3cBAv0EkQjKAAAeW+VtDQAABhkAADsdAABMHwAAuB8AAMofAAAREAH7AmZ0AUSkexQAAKHIAAAIGhWIwQAAH7Igyx5jAQPFAAAY62FlcwHEBwAAIcIAAH8N9hwFAsAAABJKEDT+FGIcxx9lZyvAAUTBAAAbm8QAAB69RQLAAAAWYR4WYh5td8gZyRmUG9ccNQHAAAAdYiByPBA9EDASFQHEAAAzmRqaGiMedIIZu8AGIcMAABkxocIAAPQUBQLFAAAWMx8TWR5ob1MD/ABUwQAAF9YBQx4AAJdnAP6ARzhMxQAAHQXHAAAebccAAB06wgAADPjiTwEAAOgDAAA2CwAAMQA=") \ No newline at end of file diff --git a/api/search.index/3447d0721b.js b/api/search.index/3447d0721b.js new file mode 100644 index 00000000..ff4275e3 --- /dev/null +++ b/api/search.index/3447d0721b.js @@ -0,0 +1 @@ +rn_("BQLBAAAUWyAkkB1jIG1u8wUBY/JBGQAAbQUFAsAAABE+HhbAH2V0GwLDAAAdj2V0E4QCyAAAIFhzdCsCxwAAHj1ucxsDywAAHtfDAAAgk2VsdCHGAADIHmIfFwYA/gBDIgnRAAAfpsMAAB+eyAAAHyLLAAAfJOQkCQAA8QoAAHkdAACFHwAAnSAAADEAAA==") \ No newline at end of file diff --git a/api/search.index/346997878f.js b/api/search.index/346997878f.js new file mode 100644 index 00000000..474c850f --- /dev/null +++ b/api/search.index/346997878f.js @@ -0,0 +1 @@ +rn_("FQLAAAAWyx4aUSBhdAgZBQLBAAA0dx54HpYgOH8fgB+uIGlsMcgAAEMfRB+nILOG/RgQAsoAACBQocgAALMaW47AAAAUzA==") \ No newline at end of file diff --git a/api/search.index/34bfc861c7.js b/api/search.index/34bfc861c7.js new file mode 100644 index 00000000..89f6bf87 --- /dev/null +++ b/api/search.index/34bfc861c7.js @@ -0,0 +1 @@ +rn_("ocUAAE8cBQLAAAARLxkWSB1ycwUCwAAAExYdExcdbHAbAsQAAByyZm4jAAPDAAAarcgAAB/OYmVu4SUSAACNFwAAEAUCwgAApPMcFxofZXQVkMAAABKsGfAHQcQAAL0UqxlvHHAc5xoA/sAKkA3DAAAggsMAABuA4AAACNQ7MAAAAQAAZAIRALgGAQD1CAAAZRABAIAQAQBVEXYAQxIAAM8UAADcFAAAJBXvAD4WAAAFGQAAmhkAAKUZAAAKGgAAtht2AGocAACoHXYA") \ No newline at end of file diff --git a/api/search.index/34fa796824.js b/api/search.index/34fa796824.js new file mode 100644 index 00000000..a4abfcd4 --- /dev/null +++ b/api/search.index/34fa796824.js @@ -0,0 +1 @@ +rn_("ocYAANMUscoAAKUeIc8AAF4gsSAFAsUAALOiHrUpH3BzPwL9GDAFygAAHyzJAAAe98sAAB9iBQLBAAAccCAXdB1hY/OCAmVzocMAAPUMBQPAAAAU5BQdkx0bJR1haXlhxQAANhA/EkASQRLKGcsZAQCBwQAAHv7t4hkAAJgcAAAMHQAAFR0AAB0dAACKHQAAYh4AAGceAAC+HgAA+x4AAAEfAAA3HwAAah8AANAfAAAAAAAAAQASAQACwAAAHPDFAAAfAGFy5z8LAADiCwAAZRQAAPAZAABLHwAAUx8AAH0fAACoIAAAIAEEAPsCZHL7/gAkGgg=") \ No newline at end of file diff --git a/api/search.index/3501e15c93.js b/api/search.index/3501e15c93.js new file mode 100644 index 00000000..3a5eafa9 --- /dev/null +++ b/api/search.index/3501e15c93.js @@ -0,0 +1 @@ +rn_("cccAAKkgqiCrIKwgrSC2ILgg8wIBZufFFAAAnRkAAK8cAAByHgAAhB4AAMwfAACVIAAAmiAAAEATEAAFAsAAABuRILKBFm95tYLAAABn/hn/GWscgh6DHpkgKhklAcUAABejIHBTGZwdFQHGAACnux5yvB+lAcMAAKvKH2YrEgEAAcEAAB65a/iYGwAA+wBuAAEA7QHEABIAowAjhQLBAAAgiml5ocYAANQe+wJicTHCAAAmHfYeiSBbwBwAwAAAGXMrAuAAABmkZG6hwgAAwxTnHwD+QETBB8AAABxd7vAUAACOFwAArhcAALUXAACKGwAAUxwAAH8cAAAYHQAAgB0AAIcdAADAHgAA3B4AACkfAABuIAAAdyAAAABRAwAAACAA") \ No newline at end of file diff --git a/api/search.index/35ab37b0bd.js b/api/search.index/35ab37b0bd.js new file mode 100644 index 00000000..c444cc6e --- /dev/null +++ b/api/search.index/35ab37b0bd.js @@ -0,0 +1 @@ +rn_("FQHCAAAW+x533xkricEAABi+IcMAAK8bdh4hyAAALB23HxUBwgAApG8cYs8UJQHDAAATVB5u0x7oHgUCwAAAFtYeFVEebHTnFQD9MckOwQAAGdrEAAAgasQAABn2zAAAH6TtWgEAAPYIAADfCgAAzRQAACEVAABmGAAAPx0AAHoeAAAzHwAAWB8AAHcfAABwIAAAhSAAAJcgAAABERMjAAAA") \ No newline at end of file diff --git a/api/search.index/35dc45fae9.js b/api/search.index/35dc45fae9.js new file mode 100644 index 00000000..dcbde71f --- /dev/null +++ b/api/search.index/35dc45fae9.js @@ -0,0 +1 @@ +rn_("BQLFAAASvx6nZR9pcoME/QgJAsoAAB+nwwAAHKHGAAAeaeUWFQAAZhsAAGEdAAB6HQAAIB8AAL8fAAAhIAAFlMQAABQmHVJyHnMedB51HpUgAQUByAAAH9Nq5LAYAACDGQAAZx0AALkfAAC7HwAACFAAJYTEAAAXBR9EHpwe+wJzdFv9EEBEwwAAHzzDAAAdpwUCwAAAIoEdwh44uh6wILUgbHKhxAAAKR5x0AAAqSCqIKsgrCCtILYguCDzAwJzdgGmIAAAocMAAJ0bZQHDAAAgfB1oHnMTFRQVFRVkHWUdWR5hxgAAPx1AHUEdMx93H3Ag+wJobwWFwAAAMsEeyB9fIBXsGCHFAAA7H1Igdz4B/TfIBsUAAB95zQAAHwTqxQcAAG4ZAAB7HQAAyR4AABAfAAChHwAArx8AAMEfAAB1IAAAhCAAALQgAAAwAAAwAAChyAAAOB0FAsAAABGRFzWzHLQc7R5kdCMAA8AAABmexgAAHLViaXODchwAADHMAAD9HhkfXCC7/YwBAMQAABi88wCGAeQDAAA=") \ No newline at end of file diff --git a/api/search.index/369b0e015a.js b/api/search.index/369b0e015a.js new file mode 100644 index 00000000..b4e0b9e6 --- /dev/null +++ b/api/search.index/369b0e015a.js @@ -0,0 +1 @@ +rn_("ocQAAMQcBYPAAAAUjRkdqx8lhMUAABSeH3kdnSAlhMQAABRjHwgdlCAVAsMAABNQHRbdHm1zNxn7wBAhzwH9kMAFxgAAHxHDAAAdccwAAB94") \ No newline at end of file diff --git a/api/search.index/371bc5b178.js b/api/search.index/371bc5b178.js new file mode 100644 index 00000000..44ad3c15 --- /dev/null +++ b/api/search.index/371bc5b178.js @@ -0,0 +1 @@ +rn_("IccAAHQfZSBRxwAAJR4mHiceKB6MIDMA/RJADcUAABnmxgAAHrjFAAAgfMgAAB556IIQAADNFwAAMxwAANAcAAD9HAAANB0AAFIdAACZHgAAayAAADIwAwAAMcEAACYWWRxaHKMA/QSBAsMAABmbxAAAH17pXQ0AALEQAAABEgAAphcAAOUXAABgHAAAaBwAAEMdAAA/HgAAWx4AADAAAAMA") \ No newline at end of file diff --git a/api/search.index/376002cf7e.js b/api/search.index/376002cf7e.js new file mode 100644 index 00000000..ba74849d --- /dev/null +++ b/api/search.index/376002cf7e.js @@ -0,0 +1 @@ +rn_("BQLNAAAWmyAXoiBjcAUCwAAAHGAfEqYYb3khwwAAuxdRIAUCwAAAGV8fFkweYXcrAscAAB6hYXQBggLQAAAgtcEAAB5LaXYVAcMAABWdH3D/HJOC/QCJCsUAAB6gxAAAHkrCAAAdAPsDYWV0BQLDAAAVSB4VSR5wc6HEAABRGQUCwQAAEXYbEaUYZmkJAtAAACCwwgAAHkdpdisCygAAIG9jchuGwQAAHGoFAsAAAB2PIBNyHW5yKwPBAAAcac0AACCHYWVsFQLDAAAVXh8Q/Rxwc2gcBQLDAAAWXSATXR9zdSOBAscAAB+cY3S3AYL9AYkKxwAAHvXDAAAc/gUCxQAAFfQeE0Yec3XzAP1IGQ6E5AsAAA==") \ No newline at end of file diff --git a/api/search.index/3776bee2e1.js b/api/search.index/3776bee2e1.js new file mode 100644 index 00000000..693af19d --- /dev/null +++ b/api/search.index/3776bee2e1.js @@ -0,0 +1 @@ +rn_("CQLPAAAgdcMAAByXaXChygAAGx0VAcQAABMSHWLPHxsCwQAAHSVwdaHCAAD5FAWJwAAAHMEfFGEb+wJjcjHEAADlGY8bdRwFisAAADLnF+gXNR5GixuMG4MchBwFAsAAABKxHBAaH2l5KwLPAAAghGVyAUE7MAAAAQAAfwAFAGAQAADKEHsAuh4AALAgAAC1IAAAawPDAAAcsmFjdAWJwAAAHKAfGcgeBQLAAAATFh0TFx1scDUCwAAAwjsZFescYWj6EfsReR7v3/5ACuwrwQAAFiXIAAAdmQ==") \ No newline at end of file diff --git a/api/search.index/389c02f3e2.js b/api/search.index/389c02f3e2.js new file mode 100644 index 00000000..2930f272 --- /dev/null +++ b/api/search.index/389c02f3e2.js @@ -0,0 +1 @@ +rn_("AYICZimvj0/gAAASFzBzI4KG4AAAC8AFg8AAABEECxWBG2HBAABgCm8UcBRxFHIUqxjXAAD9gCEJwgAADRHJAAAfuOtDCgAARgsAABQSAAAvEgAASxQAANcUAABbGQAAchkAABQfAABNHwAAUh8AAM0fAABAEAIAAAA=") \ No newline at end of file diff --git a/api/search.index/389da3c261.js b/api/search.index/389da3c261.js new file mode 100644 index 00000000..7288290a --- /dev/null +++ b/api/search.index/389da3c261.js @@ -0,0 +1 @@ +rn_("BQLEAAASvx6nZR9pciHFAABVGaweocYAAIUcFQHEAAAWrR5yhx93AQDCAyjFAAAch/WpGAAAiAMeAQkAQgAFicAAABZpHhR0GaUBwAAAoPMRc0QOGwLCAAAcoWRuFQHDAAAWfR1yHB8XEwD9HokDwwAAF4HKAAAfp8AAABjhwgAAFNDFAAAetu71BwAAABIAAAwSAAAWFQAA4xgAANQZAABmGwAAYR0AAG4dAAB6HQAA6R4AACAfAABQHwAAvR8AAL8fAAAAAhISECAAAA==") \ No newline at end of file diff --git a/api/search.index/38b73ed5e3.js b/api/search.index/38b73ed5e3.js new file mode 100644 index 00000000..9b29983a --- /dev/null +++ b/api/search.index/38b73ed5e3.js @@ -0,0 +1 @@ +rn_("FQHFAAAWrR5yhx8VAcMAABZ9HXIcH/ODAmJzFQHEAAAWdRxyNB7BxwAABBr7AnBxQcIAAFwNoBdCGZ0dQcAAAPkItBC1EIkXzwP9kUFBwQAAF5DEAAAX1MEAABBa") \ No newline at end of file diff --git a/api/search.index/38f2c98a4a.js b/api/search.index/38f2c98a4a.js new file mode 100644 index 00000000..49173cbf --- /dev/null +++ b/api/search.index/38f2c98a4a.js @@ -0,0 +1 @@ +rn_("AQICzAAAIKPLAAAgkXJ0464XAACKGwAAfxwAAIAdAABQANHIAADKHDuHwQAAHo0FAsAAABhRIBReHHJ0IcEAALoYDR8lAcEAABZhIHJrED8f+wJydPsDYW9yBQPBAAAz8RTyFIoYFK0eFX4cYXFzIcQAAKwaiRslgcEAABSCID4WBRkrAsEAAB0TdHoFAsIAABknHxqaH212AUQ7MAAAAQAA7QACALYbdgCoHXYAE4GDwAAAGqshxAAAYB5pHy8B/RUQA8IAABqqwAAAGPPFAAAfZ8oAAB6fFYjFAAAfsiDLHisCwAAAEMhmbPcsAP2RaQ/CAAAffsUAABy1xAAAGZjDAAAfDzowAAABAAAAAAAsABAAAABXCgENQg1DDWwNdhDzEfQRkRfTFwgZrRqgG7AbtBtuHJIcqhyrHKwcsxy0HOwc7RzuHHQdJB5gHmoebR5wHnEewx7tHg4fQh9aH2kfeB+HH6AfZCB9IKEgpSA=") \ No newline at end of file diff --git a/api/search.index/39a09f0c43.js b/api/search.index/39a09f0c43.js new file mode 100644 index 00000000..e3250785 --- /dev/null +++ b/api/search.index/39a09f0c43.js @@ -0,0 +1 @@ +rn_("BQLAAAAVoxoXzB9ldAUCwAAAErEcEBofaXkFisAAABPyGBLmFyHCAADlF2AcxQHCAADFLx5u7hgbAsYAAB2jZHSrwATE4wAAEL3LAAAfeQUCwAAAVaEaohqOHlkftyAfhCBwcrHQAABTIFc1AP2aMwPHAAAgfcIAABfkyAAAHaTFAAAgsuNeCwAAqBkAAG0dAAC0HwAAIgA=") \ No newline at end of file diff --git a/api/search.index/3aca1c316a.js b/api/search.index/3aca1c316a.js new file mode 100644 index 00000000..fb200e00 --- /dev/null +++ b/api/search.index/3aca1c316a.js @@ -0,0 +1 @@ +rn_("BQLAAABz1xTYFNkUFB9NH1IfzR+ikx5udQUCwQAAEDMWFzgfZ3QFAsAAAB51Hx92H29yYwD9MAEDwAAAGH/KAAAdLcQAAB+qAaMfAAD7Amtv") \ No newline at end of file diff --git a/api/search.index/3adf8989a7.js b/api/search.index/3adf8989a7.js new file mode 100644 index 00000000..8606b78f --- /dev/null +++ b/api/search.index/3adf8989a7.js @@ -0,0 +1 @@ +rn_("tQLBAACkAhkUBBlicY8LBQLDAAAWWR8YzB9lcyHIAAB6GW8gOwPCAAAQyWlyc/sCcnShwgAAQhAhxwAAqRwGH7HLAADpHkWQxAAAoq8cjhcpHyofKx/7AnR1FQHGAAAQbx5zfBzzAwFw4goSAAAZFgAAoh4AAAAgBQLAAAAbfyASDx1ydBuJygAAIHMrAskAACBnZXQxxwAAVR7EHskfNQHFAAAW0B93gx1rHpkfBQLCAAAYZiAafiBydfuJFYLDAAAUMh97HxOBg8cAAB+q1wECwBJixwAAHz7h3RcAAAsdAABAK4bPAAAgaDHDAACZGpoaIx5DAQPDAAAbrcYAAB+FZW9051ARAAAJEgAAghkAAP4ZAABrHAAAgh4AANYeAACZIAAAQAEBAPsCb3YVAcEAABMFGXNBHwUCwwAAFacdtGEdc3RLA8UAABnQwAAADSNiaXX3DgD9EYEPxwAAH6YBAhUAAHHAAABhGzEdfB1oHmgfcSCRIAXAwGAQAAAWeRkXlxsYkRxLwASCygAAHujJAAAcrhsCwQAAC2FleRWCwwAAGo0fWxYTggLEAAAe3GFzccIAAEoUxRfGF8cXkx6UHgofFQLBAAC0dh0XWxtjcO8KBQLAAABSqReqF6sXjRuOG0KQHcgeYh9jIGVyAUDl1xQAAFsZAAAUHwAATR8AAFIfAADNHwAAIAAAE4ECxgAAHv9ncqvADIQeyaESMMQAAB9BFQHHAAAQrR9zfx0rg8EAACBbGwLEAAAZxm1wBQLAAAAkoR/BHxF0IGFyG4bBAAAgs98H/gACjgzGAAAZggUCwQAAHd8eEzEfaW0FAsEAABAzFhc4H2d0BQLAAAAedR8fdh9vcmMA/TABA8AAABh/ygAAHS3EAAAfqgGjHwAABQPAAAAY9hkdbB6zdxthZHMBAoLDAAAfbOSnFwAAuRcAAOkYAAAzHQAAjB0AABEQEBOCAsEAABTmZ25RzwAArx+wH7Efsh+0IKUBxAAANf0eGR9cIGI3HgUDwgAApJMeFiMfFPAeY2Vxm8ACicQAAB5DyAAAHiDzAP2RTQ/lZQEAAJYIAAATFQAAhBcAAGQdAABZHgAAAiQQ") \ No newline at end of file diff --git a/api/search.index/3b0004b4c1.js b/api/search.index/3b0004b4c1.js new file mode 100644 index 00000000..7aa5916d --- /dev/null +++ b/api/search.index/3b0004b4c1.js @@ -0,0 +1 @@ +rn_("NQHEAAASXB5r6BmEH5gfI4ICxgAAHohzdLHBAABqFAGCAtEAACCcxgAAHodlc1cBAP3AIQbHAAAdE8IAABiAxgAAHk/2JwsAAI0AAQCoCFQE7wWhxQAASRwTAgLFAAAfW2Z05BMZAAD1HgAAfx8AAJUfAACuIAAAMBAAawPgAAAe/mdpeQ==") \ No newline at end of file diff --git a/api/search.index/3b6fc98dcb.js b/api/search.index/3b6fc98dcb.js new file mode 100644 index 00000000..fc843f6d --- /dev/null +++ b/api/search.index/3b6fc98dcb.js @@ -0,0 +1 @@ +rn_("BZTEAAAUJh1Sch5zHnQedR6VIAEFAcgAAB/TauSwGAAAgxkAAGcdAAC5HwAAux8AAAhQACWExAAAFwUfRB6cHvsCc3Rb/RBARMMAAB88wwAAHac=") \ No newline at end of file diff --git a/api/search.index/3b7443dbb4.js b/api/search.index/3b7443dbb4.js new file mode 100644 index 00000000..43f19138 --- /dev/null +++ b/api/search.index/3b7443dbb4.js @@ -0,0 +1 @@ +rn_("ocUAAE8cBQLAAAARLxkWSB1ycwUCwgAAExYdExcdbHDHAYH+AAqADMIAABmsyQAAHxoFAsEAABDOHxGPGGRp+wJzevMAhIIUCQAA") \ No newline at end of file diff --git a/api/search.index/3bce6f6729.js b/api/search.index/3bce6f6729.js new file mode 100644 index 00000000..a17fb984 --- /dev/null +++ b/api/search.index/3bce6f6729.js @@ -0,0 +1 @@ +rn_("scMAAIscBQLAAAATFxkQ/hxkc/sCcnbzAII6MAAAAQAAAAAAEwAQAAAABgsHCwgLVBQBFaUXehk4HHocuhzYHA8dfh2PHZsdPh92H6wfbyCTIA==") \ No newline at end of file diff --git a/api/search.index/3c095dfa7a.js b/api/search.index/3c095dfa7a.js new file mode 100644 index 00000000..79e50f0a --- /dev/null +++ b/api/search.index/3c095dfa7a.js @@ -0,0 +1 @@ +rn_("BQLBAAATDRkWMhxmayuJwQAAHT2xwgAADA0BAQHRAAAgY3IBfA0AAPsCcnVVAsYAABiBHxmuH2NzCRkKGQsZDBmTHwWSwQAAEXofFC0c+wJpdvsDYW9y") \ No newline at end of file diff --git a/api/search.index/3d0d7e8b9b.js b/api/search.index/3d0d7e8b9b.js new file mode 100644 index 00000000..c6b0986b --- /dev/null +++ b/api/search.index/3d0d7e8b9b.js @@ -0,0 +1 @@ +rn_("ocUAAPsYFQHGAAATfh9sJB4jAgLCAAAcLmxzASQWAAArhsAAABBJIwAC4AAAH6RzdAFYCgAAocUAAE8cBQLAAAARLxkWSB1ycwUCwgAAExYdExcdbHDHAYH+AAqADMIAABmsyQAAHxoFAsEAABDOHxGPGGRp+wJzevMAhIIUCQAA+wJsdDMAA9EAACBjYWVyAXwNAAA=") \ No newline at end of file diff --git a/api/search.index/3d478f4566.js b/api/search.index/3d478f4566.js new file mode 100644 index 00000000..90820d09 --- /dev/null +++ b/api/search.index/3d478f4566.js @@ -0,0 +1 @@ +rn_("AQECwgAAHRLDAAAfjGJp6yENAADiFAAAvxgAAH4cAACbHAAAVx0AAF4eAAC4HgAAux4AAPoeAABlHwAAzx8AABAAEAAQEBWEwgAAGSIexhn7AmVz") \ No newline at end of file diff --git a/api/search.index/3d4a4916ef.js b/api/search.index/3d4a4916ef.js new file mode 100644 index 00000000..2d358201 --- /dev/null +++ b/api/search.index/3d4a4916ef.js @@ -0,0 +1 @@ +rn_("cccAAKkgqiCrIKwgrSC2ILgg8wIBZufFFAAAnRkAAK8cAAByHgAAhB4AAMwfAACVIAAAmiAAAEATEAAFAsAAABuRILKBFm95tYLAAABn/hn/GWscgh6DHpkgKhklAcUAABejIHBTGZwdFQHGAACnux5yvB+lAcMAAKvKH2YrEgEAAcEAAB65a/iYGwAA+wBuAAEA7QHEABIAowAjhQLBAAAgiml5ocYAANQe+wJicTHCAAAmHfYeiSBbwBwAwAAAGXMrAuAAABmkZG6hwgAAwxTnHwD+QETBB8AAABxd7vAUAACOFwAArhcAALUXAACKGwAAUxwAAH8cAAAYHQAAgB0AAIcdAADAHgAA3B4AACkfAABuIAAAdyAAAABRAwAAACAAKwLEAAAZTmNt") \ No newline at end of file diff --git a/api/search.index/3db5ad32b7.js b/api/search.index/3db5ad32b7.js new file mode 100644 index 00000000..e08fc957 --- /dev/null +++ b/api/search.index/3db5ad32b7.js @@ -0,0 +1 @@ +rn_("BQLAAAAVJx0ZMh1pbQUCwQAAFOgcIlwdHx5mdLHHAAByHBOCAtIAACCQZXMFAsAAABgLHRMmH250GwLMAAAfMG51GwLNAAAfMW9ynwr+AAewC8YAABlh0QAAH87EAAAXiscAABt10AAAH6I=") \ No newline at end of file diff --git a/api/search.index/3db69bc222.js b/api/search.index/3db69bc222.js new file mode 100644 index 00000000..49e48b48 --- /dev/null +++ b/api/search.index/3db69bc222.js @@ -0,0 +1 @@ +rn_("McQAAK8eNB9sHxMAAskAAB5qZWbnpxcAALkXAADpGAAAexkAAJUcAAAzHQAAjB0AAHwfAAARERAQEwECwwAAHzJkZQF7HwAA") \ No newline at end of file diff --git a/api/search.index/3dbdf5c6fc.js b/api/search.index/3dbdf5c6fc.js new file mode 100644 index 00000000..76fb75f5 --- /dev/null +++ b/api/search.index/3dbdf5c6fc.js @@ -0,0 +1 @@ +rn_("UccAACUeJh4nHigejCATAP0SQAjGAAAfdMUAABnmyAAAHnnkghAAANAcAAD9HAAAUh0AAJkeAAAwAACDAv0EgQLDAAAZm8AAABYmxAAAH17iXQ0AAGgcAAA/HgAAMAAVAcEAAB3UH3LvHAkC0AAAIFzAAAAebmNmBQLCAAAW5RlSDhkPGRAZERmUH3J1G4PgAAAM/XHCAABFGUYZRxlIGcQZxRlmHN8D/QxwBOEAABj0") \ No newline at end of file diff --git a/api/search.index/3ddd6f5f21.js b/api/search.index/3ddd6f5f21.js new file mode 100644 index 00000000..fe034a0f --- /dev/null +++ b/api/search.index/3ddd6f5f21.js @@ -0,0 +1 @@ +rn_("ocYAAPkZIcYAAGYdwB8hxAAASRskHyHBAAChGD4eBQLAAAATtBsQcSBvc2cDAP0WAQbCAAAdEsMAAB+M6yENAADiFAAAvxgAAH4cAACbHAAAVx0AAF4eAAC4HgAAux4AAPoeAABlHwAAzx8AABAAEAAQEBcCAP0wgA7HAAAgV8UAABz2zAAAH8fDAAAdj+mpDQAAFxIAAEgUAAAdFQAAkxcAAGoYAACBGAAA4hgAAOYZAACtHwAAIBMAAAA=") \ No newline at end of file diff --git a/api/search.index/3e495d13f2.js b/api/search.index/3e495d13f2.js new file mode 100644 index 00000000..51014c78 --- /dev/null +++ b/api/search.index/3e495d13f2.js @@ -0,0 +1 @@ +rn_("UcIAAD0cPhw/HEAcbSABROTjGAAA1BkAAG4dAADpHgAAvR8AABISABWExAAAJPYeiSAYHRMAA9UAACCxxgAAHXN0dXeC6REAABUBxgAAoAMdcwAaBQLCAAAYYR8TpB9jbbcDAP4ACGAHxwAAHZHyJA0AAGwT") \ No newline at end of file diff --git a/api/search.index/3fbbae3dfd.js b/api/search.index/3fbbae3dfd.js new file mode 100644 index 00000000..189b443a --- /dev/null +++ b/api/search.index/3fbbae3dfd.js @@ -0,0 +1 @@ +rn_("JYTFAAAUnh95HZ0gJYTEAAAUYx8IHZQg+wJsciuKxgAAHxElAcAAABR2H3QwHKUdUcIAALsavBpjHIMgiCAlAcEAABPzGHNEDSAZ+4ahwgAAORABQzswAAABAAB/AAUAYBAAAMoQewC6HgAAsCAAALUgAADnAwD9FAEHxgAAHzXybRkAADcC") \ No newline at end of file diff --git a/api/search.index/406ce9674a.js b/api/search.index/406ce9674a.js new file mode 100644 index 00000000..3e41f586 --- /dev/null +++ b/api/search.index/406ce9674a.js @@ -0,0 +1 @@ +rn_("BQLAAAClmhcoTRx+IHByGwLAAAAVEm5yMcMAAPMUixiMGCHEAABEFkkeFYHGAAAS/h6KHSMDAsQAAB0ZaHfynxcAAFsF+wJ0eQUCwQAAJqcZZiAVJRlhcAFE4xkSAABAFgAASB4AAJAeAAAzABsCxwAAHqhscgFF4yEZAABwGwAAlh8AAFkgAAAzAAWSwQAAIT8WbRwSJR/zgQJpdnUBwQAAop0XchgLGQtoGWkZARoCGvIeBQLGAAAYRx8ViR5jZiuKxQAAGSAhyAAAphlNHvc+AP08gQ/BAAAQR+MqCwAAwQsAAJ8cAACyIAAAQQA=") \ No newline at end of file diff --git a/api/search.index/40dc0dabff.js b/api/search.index/40dc0dabff.js new file mode 100644 index 00000000..c0e6fd5b --- /dev/null +++ b/api/search.index/40dc0dabff.js @@ -0,0 +1 @@ +rn_("scoAAKUeIc8AAF4gsSAFAsUAALOiHrUpH3BzPwL9GDAFygAAHyzJAAAe98sAAB9iBQLBAAAccCAXdB1hY/ODAmVz") \ No newline at end of file diff --git a/api/search.index/412b53e786.js b/api/search.index/412b53e786.js new file mode 100644 index 00000000..f1098493 --- /dev/null +++ b/api/search.index/412b53e786.js @@ -0,0 +1 @@ +rn_("UcIAALQNtQ22DZMbtRwVAcAAABALCTDeBxUBwAAAEAoJMN0HNwQACOAAAAffwQAACyTgAAAH4MEAAB9oxgAAIHExMjQ3OGVoaYIsHgAA") \ No newline at end of file diff --git a/api/search.index/41ad747352.js b/api/search.index/41ad747352.js new file mode 100644 index 00000000..63d625e4 --- /dev/null +++ b/api/search.index/41ad747352.js @@ -0,0 +1 @@ +rn_("BcDDgGAAABKzHhV5HCV6HJsdoccAALQeIwP9MAEFyQAAIHHBAAAU/8UAAB82xwAAH8PsGQ0AAJ0NAADtGAAAnxkAAGQbAACQGwAAgBwAACkdAACGHwAAjh8AAIcgAACiIAAAryAAADEAEAAEAAAVkMAAACHdChoV4wMFAsAAABINHRF3HGl0BQLAAAAQ8R4jeByCHXJ2U4IDyAAAH6NlZnTzAAFk8rMIAACPCRsCxgAAHQ5udBXAwQhKAAAXYB8zUB1BHlggF24bNtsZ3BndHjcZBcLBEEAAABGCGBOlGRVLHAUCxAAAE8gcFD0dZGYzgcAIZMgAAB0+wgAAFj77AmVsMcUAAP0eGR9cICdrAP1ryQPUAAAgdMEAAAu2wwAAELPgAAAYgsQAABul41ULAAAFHQAANx4AAFwfAAAQEA==") \ No newline at end of file diff --git a/api/search.index/41c4d5699d.js b/api/search.index/41c4d5699d.js new file mode 100644 index 00000000..48f2b293 --- /dev/null +++ b/api/search.index/41c4d5699d.js @@ -0,0 +1 @@ +rn_("wcMAAFcbNQHBAAAWAR939xn4GV8cocoAAG0f84ICY2UTggLCAAAdk2lzocUAAHcZYcIAAJkamhqFG4YbhxsjHiHIAABBHlggAQMByQAAIKVm4bAZAABJHwAAgbHHAABUHfv9gEFAocYAAPkZBQPAAAAUtBshoRg+HiZmHcAfY2V0FwIA/TCADscAACBXxQAAHPbMAAAfx8MAAB2P6akNAAAXEgAASBQAAB0VAACTFwAAahgAAIEYAADiGAAA5hkAAK0fAAAgEwAAABUBwwAAFrAedmwZI4ECygAAH9Rwc6UCxQAAFmEeFmIebXfIGQXAwAgkAAAUjiAWhR8ksxtbHAFLOzAAAAEAAHoAAQDVH3oAG4PIAAAfZFHAAAAiFiMWWRZaFpEepQHFAAAW0R93iB37AmFzIcIAAJUZxh4FA8AAABUFHxEEFRdvH2FmdAFA4bQUAABLGwAAFvsCZG4FwcAALAAAI3EcLh4anx8TrRujbR+hxQAAexkVAccAABCcIHMRHTsDxAAAHp9haG8FAsAAALC7GRHgFGxyAQDwAAAAAAAAAQAAAAAAavp9AmYpr49PRgLEiZISiBgf2Vdz+3mvxgAAHTEFLqAtO0kQ75L+wAAAEf9QzTijGUfNQCy+f2Y+LPY+Qw7WgzNUDX3kRWLLAblP0+UoQST1/VZKZebm4XIUj7TRnzowAAABAAAAAACHABAAAAAWCTwJPAvWC9cL2AvZC/EM8gzzDP8MSw1MDU0NTg1PDVANUQ1SDVMNVA1VDVYNVw1YDVkNWg2ADYENgg2DDYQNhQ2GDYcNiA2JDYoNiw2MDY0Njg2PDZANkQ2SDZMNlA2VDZYNlw1hELsQvBBIEVARURFSEVMRVBEJEgsSKRLTFNQU1RQGFScWKBZ3FoIXgxeuGOAYGxkcGR0ZYRliGWMZZBmCGdIZ3Rn+Gf8ZoxpGG0kbWxt3G3gbeRt9G6IboxtnHGsciByJHIocoBykHMMczxzvHAcdIR0uHU4dfx2THZUdQB5EHkUeTh6CHoMenB60HrUe1h7gHgcfHR8fHyQfOh9wH3IfiB/UH10gmSCjIA==") \ No newline at end of file diff --git a/api/search.index/41d962fd63.js b/api/search.index/41d962fd63.js new file mode 100644 index 00000000..b599e9ce --- /dev/null +++ b/api/search.index/41d962fd63.js @@ -0,0 +1 @@ +rn_("AUU7MAAAAQAAfQACAA4SAQDME3sACYnQAAAgZcYAABulBQLAAAAfcx8cCx9hdCHBAAAbC9AZCQLWAAAgtsYAAByoZHQrAsUAABoNZnV3AYH9cSgAzgAAH3TMAAAfDGsDwAAAB+5lbHY=") \ No newline at end of file diff --git a/api/search.index/41f6cb8147.js b/api/search.index/41f6cb8147.js new file mode 100644 index 00000000..f472f312 --- /dev/null +++ b/api/search.index/41f6cb8147.js @@ -0,0 +1 @@ +rn_("BQLCAAAZ7R4dnCBkcxsCwwAAGqljdLHKAAD3HAWKwAAAFqkaE+oXodQAAIAgocEAAPoRWwPgAAAZ2WVpbcHHAAClGgUCwAAAFswZF2EcZHYhwwAAIxX5HgUCwQAAE7EbFqYdZmsjgYnIAAAdpRUBxgAAFQ8fc6QaQwADwAAAF9zDAAAam2Fjb/Y/EAAAAQBmCacE4AAaAAUCwQAAGw4fGW0eYmwBAP5AS+gvT1dF1ErLAAAdpyTzfwrmXIm85icFTbMFaV7VcRMxStOZwyTGAAAe2yFU6xpYxAAAHvQ/hqrfYMIAAB5GWUVDjyFUHFVH0PNJCgAACxHhAw==") \ No newline at end of file diff --git a/api/search.index/4234ab5389.js b/api/search.index/4234ab5389.js new file mode 100644 index 00000000..4aa8060a --- /dev/null +++ b/api/search.index/4234ab5389.js @@ -0,0 +1 @@ +rn_("AQOBwQAAHv7t4hkAAJgcAAAMHQAAFR0AAB0dAACKHQAAYh4AAGceAAC+HgAA+x4AAAEfAAA3HwAAah8AANAfAAAAAAAAAQAS") \ No newline at end of file diff --git a/api/search.index/429152e398.js b/api/search.index/429152e398.js new file mode 100644 index 00000000..f05672f5 --- /dev/null +++ b/api/search.index/429152e398.js @@ -0,0 +1 @@ +rn_("FYTFAAATYBy0HxUBwgAAFrceZFMbE4ECygAAHnZhZAXAyAIiAAAVex8WmR8XyR8rwAiEwQAAFEvCAAAWdgUCwAAAEMEZFu8cZXD7Am1uBYbAAAAcyh4SQxKhxwAAZR8hxgAAnBcmGSHCAABzHb8eUcYAAJ4dnx2gHaEdiyAVgsEAABF/GKMfGwLgAAAeTGNkBQLAAABUZhhnGGgYaRhYHxi/H2519w4A/kBkyAHQAAAgaYICGQAABQLAAAASmhkj/RnQHml0FQHAAAATshpwqh5BxQAAFB9NH1IfzR9zAsIGQMUAAByY4SoSAAB7FgAAARvAAojIAAAfTsoAAB6z8wD9gUAJAWQBAAA=") \ No newline at end of file diff --git a/api/search.index/4329a9ef83.js b/api/search.index/4329a9ef83.js new file mode 100644 index 00000000..a1aa1d79 --- /dev/null +++ b/api/search.index/4329a9ef83.js @@ -0,0 +1 @@ +rn_("BQLJAAAUlCAVnSBschUBwgAAG4ggdNMcJQHGAAAW0h93jh3ZHgUCwgAAFrQfG5MgY3KhxgAAjB23AwL9RBEGzAAAIIPhoxcAADMZAAATK4nHAAAekA==") \ No newline at end of file diff --git a/api/search.index/4373fe1e45.js b/api/search.index/4373fe1e45.js new file mode 100644 index 00000000..bc9a2f99 --- /dev/null +++ b/api/search.index/4373fe1e45.js @@ -0,0 +1 @@ +rn_("pcHAAEYAABknH89jHhxcHxqaHzkJsccAAHIcGwLSAAAgkGVzBQLDAAAZJx8amh9tdgUCwQAAE08ZEqIYcHSHAYHABmjIAAAdcMYAABtqywAAHvBbwAChwAAAHk9RwAAAtA21DbYNkxu1HDHBAABkELcaZx8VhMEAABPYGDodBYbAAAAVaR8TEx0jAQLFAAAdBnJz8usRAACGDW8d/QjIH+AAAArexAAAGOLJAAAgkA==") \ No newline at end of file diff --git a/api/search.index/438175ab28.js b/api/search.index/438175ab28.js new file mode 100644 index 00000000..1b2af77c --- /dev/null +++ b/api/search.index/438175ab28.js @@ -0,0 +1 @@ +rn_("ZYHFAAAS/h7iGZgcHR2KHb4e+x4FwMICoAAAFiYfFugcFicdFQLEAAAVaB6jnRtjbHsd0wD9BQEFzAAAH6HJAAAZdDswAAABAAApAAMAKQMjAPQMAAB2DQQA") \ No newline at end of file diff --git a/api/search.index/43dc878e27.js b/api/search.index/43dc878e27.js new file mode 100644 index 00000000..47179b05 --- /dev/null +++ b/api/search.index/43dc878e27.js @@ -0,0 +1 @@ +rn_("FQHCAABQvxzAHMEcwhx7IHM7E6HFAABJHBMBAsUAAB9bZnTkExkAAPUeAAB/HwAAlR8AAK4gAAAwEABrA8EAAB6wYWV0") \ No newline at end of file diff --git a/api/search.index/446aba0c66.js b/api/search.index/446aba0c66.js new file mode 100644 index 00000000..7deb67c1 --- /dev/null +++ b/api/search.index/446aba0c66.js @@ -0,0 +1 @@ +rn_("FcDBCEoAABdgHzNQHUEeWCAXbhs22xncGd0eNxkFwsEQQAAAEYIYE6UZFUscBQLEAAATyBwUPR1kZjOBwAhkyAAAHT7CAAAWPvsCZWw=") \ No newline at end of file diff --git a/api/search.index/44b34cc91d.js b/api/search.index/44b34cc91d.js new file mode 100644 index 00000000..08b5ae95 --- /dev/null +++ b/api/search.index/44b34cc91d.js @@ -0,0 +1 @@ +rn_("IcIAAOUXYBzFAcIAAMUvHm7uGBsCxgAAHaNkdLHCAABiGRUBwAAAHc8fYpQd84KGFQHDAAAW+x533xkhwwAArxt2HiUBwwAAE1QebtMe6B7zAMAIiIIhFQAAg4H9EgADxQAAHiLDAAAZ3sQAABt/vwL9DSAG4wAAEL3LAAAfeQ==") \ No newline at end of file diff --git a/api/search.index/4553044c01.js b/api/search.index/4553044c01.js new file mode 100644 index 00000000..500206c4 --- /dev/null +++ b/api/search.index/4553044c01.js @@ -0,0 +1 @@ +rn_("IcAAAHwdaB7zAgFz4xMVAACEFwAAZB0AAFkeAAAkEBsC4AAADZ9kbBUCxAAAJxQdYCAdjiBtcCIfIcIAALIQJBL7AmFjIwDCAGDHAAAZ9cgAABuW4+wDAAB5BgAAKwkAAA8NAAABEGHGAAA/HUAdQR0zH3cfcCAFBMIAACIKFageMeUY5hjXGRapHhQ/GWlvcHEFisAAABfWHhVqICHIAABPH5cfIcMAAFAQbRQFAsAAABB0GxZ1H2JvNwUA/Sg1CsMAAB9jwQAADQvIAAAZ6cEAABrC49oHAAD7BwAACB0AAJQgAAAgAEUCwgAApF0dE18dZGlJGZ0eWx98IFvABgHEAAAcN/v9kUQA") \ No newline at end of file diff --git a/api/search.index/458ffe9ae6.js b/api/search.index/458ffe9ae6.js new file mode 100644 index 00000000..485cc950 --- /dev/null +++ b/api/search.index/458ffe9ae6.js @@ -0,0 +1 @@ +rn_("BYzAAAAcoSAULRkFAsAAACQHHS4dEKwZYWwrAsUAAB+DbHIFAsAAADKgF0IZnR0SWhBpeRUCwgAAHaMgHJEgcnSKG9HIAADKHPOBgzHFAADxFPIUihghwgAAPhYFGQFGOzAAAAEAAO0AAgC2G3YAqB12ACsCwAAAGPNlcj8F/RBhB8UAABy1wwAAG4nEAAAZmAUCwQAAIR4dtx4ZYiBsbUvAAFTGAAAdPsMAAB8AK4vBAAAfKMAAABBfBQLAAAARgx8hshqiHXN0G4PDAAAXizUDwAAAF2cfEw0fFA8fZXR1oBtxHsMe8wUBc9SuGQAA9r5TMcAAAB0NHg1HG2vCAAzNAAAgsgXCwRBAAAARghgTpRkVSxwVhMQAABPrGYcYBQLEAAAyfx+AH64gELEYaXkrhsIAABinNQHAAAASVh5jdx54HpYgMcIAALMctBztHlMC/QCBBsQAABt+yQAAH6jiBBIAALAaAABdHQAAEBAFAsEAAB2mH1jMHs0ezh7PHp8gbnYbAsUAABzJY3AhwAAAbhCbHlUBwwAAEI8ec1YQVxBzFHQUdRQlAcQAALAqGXPwFIcdVQLEAACxwxqwJxlpc+wU7RTuFO8Uhh2xwwAAhRulAcYAABbRH3eIHfOEAm10NQHDAAAWFR136xSFGIYY+wJyc/sDZW90BQLAAAAiTx+XHzTpFOoUhBhhbAUCwgAAGNYeEsIac3VBwwAA5xToFMIfViDzgQJhY6HDAABUEAUCwAAAoHMNclEQUhBTEOsZuBqtHKgfZHTnDQD9BI0HxQAAHyzIAAAZ6uS0CAAAiRAAAIcYAADyGAAAoRkAAHAAAJ8D/QkgB8AAABeRxAAAH8YFAsIAABPIHBQ9HWRmKwLAAAAYu2xyK4XIAAAeajWCwwAAFh8drx40H2wfFQHDAAAW+x533xkhwwAArxt2HiUBwwAAE1QebtMe6B7zAMAIiIIhFQAAQ4MDwwAAGd7EAAAbf2VycysC4AAAHEJkdAUCwwAAFfQeE0Yec3X7wQAFAUQ7MAAAAQAA9gADANcYAADGGnoA1R96ABOBg+EAABxGG5nGAAAfWsUAABzPVQHCAAAToh9k7BeRGJgYmRibGiOBAsQAAB10bXUVAcEAABGBG3SeGQUCwAAAFnAgo2QdaG/zAwFj4a8fAAC0IAAAMHWCwAAAwDsZLQ3HEMcTwhTcF6MZ8BwFAsAAABAfDRZcHGRtEwH+AKRAAcUAABnAyAAAHVnJAAAePMcAABzpAfAKAACfAf0ECA7IAAAdmsIAAB7fAQD+wG++Dz/HyM3OTp6j5iUx2SVW7gn5LbypZSQgeFp9sZKmQ8QAABzrBgL9S8wcl6kfbhY+PZyUE43brfkG9khMXhklb/B6FGjFSIVsOgAfchofabJBYVOa3bVdL4hRIjswAAABAAAwAV0AzAcAAPAHAAC8CAUAAwkCABkJBAAiCQEANQkAAFYKAADsCgAAAwsAAD0LBQCLCwIA4gsAAAgNAAAfDQEAKw0BAEQNAABrDQAAdQ0AAKYNAAC6DYkANRAAADwQAQA/EAEAbBAAAJYQCgBLEQAA5BECAPoRAQAZEgMALRIBADASAAA6EwAAWxQAAGUUAADnFAEAPxYFAJoXAQCnFwEAuRcBAHoYAwB/GAAA2RgAAOkYAQAgGQAAJRkAAFUZAAB0GQAAexkBAKAZAACmGQAA8BkBAPkZAQBBGwAAgBsAAKYbAgBJHAEAbRwAAHEcAwCVHAIAshwAANEcAAAGHQAAJB0AADMdAACMHQEALh4AAEgeAQBMHgEAeR4AAIYeAACJHgAAkB4AAKoeAACsHgAAyx4AAAMfAAAjHwAALR8AADAfAQBBHwAARx8BAEsfAABTHwQAeh8AAHwfAQCJHwAAnx8AAKYfAADCHwEAzh8AAFYgAACoIAAA") \ No newline at end of file diff --git a/api/search.index/46a4a0f37f.js b/api/search.index/46a4a0f37f.js new file mode 100644 index 00000000..0e0dbef6 --- /dev/null +++ b/api/search.index/46a4a0f37f.js @@ -0,0 +1 @@ +rn_("AULjThYAAJkaAACFGwAAIx4AABEgBQPAAACnahkm0hyrHxLiF2hveUUDwAAAFaMaF8wfFSEeZXR1UwpUClUKpBghwAAA3hgaH2UCwQAApmUZIpQb1xxjbDIdkB3IHiwfYh9jIGsDwQAAHLFpb3khxwAAQR5YIBMAAscAABq4Z207MAAAAQAAdwIFAOEDAAB4BfUARAsAADwP9QA8E4kABYPAAAASkBwVsiAFisAAABPyGBLmFwUCwAAAEmQcW68fsB+xH7IftCBveSHCAADlF2AcxQHCAADFLx5u7hgbAsYAAB2jZHQVAcEAAGRFGUYZRxlIGcQZxRl0OQsBAAHJAAAgpWbhsBkAAEkfAACB8wABc4K0GAAAscIAAGIZFQHAAAAdzx9ilB3zgYb7AmFrFQHDAAAW+x533xkhwwAArxt2HiUBwwAAE1QebtMe6B7zAMAIiIIhFQAAg4H9EgADxQAAHiLDAAAZ3sQAABt/vwX9DTAG4wAAEL3LAAAfeQUCwAAAEuQXtlQdaXIhwwAA4xfYHiHBAADGEzMeMwEDwgAAGGVpdHXqHQsAAFgLAADjFAAAdhYAAKkXAADuGQAAiBsAAI0bAACFHAAAJh8AAJsgAAAFACEBEAAVAcIAADGjHTgfbx9tKB0hzgAAEB+EIFMAwQEExgAAGL4BzAsAAGHFAAChGqIarhyOHlkftyBlAcEAABPiF3QpFioWKxYsFi0WghwBReT9GAAAmRwAAPccAAAbHQAAHx0AAEEhAO8DxAQu4AAAG0GhwwAAtBhBxgAAqhyrHKwcZCAFAsAAABSuGxUwH2Fs0wPAAsHDAAAgfdSbHwAABwf1sdAAAFMgVQHDAAAQSR1zbxBwEHEQchB1GBuUygAAH4IBAPAAAAAAAABBAAAAAAA29xwC4AAAHlrAAAAH2WIe2cg0TZL4H2xmqz73EnrsGkFBIPuafuAi9/9OQsgAAB2kDGf5egAAE7hnaxDMjO9JGUi9KjV7s3o4gwexO/UyGE5DTwcfIeayNTswAAABAADgADoAWwEAAAIIAAD9CAEAPgoEAF4LAgDJCwAA9REAAA4SAQASEgEAzBN7AEoUAABmFAEAnxQCAP4UAAAMFQAAKRYJADoWAABgFgIAgRcAAMUXAgDkFwAA7BcAAJEYAACYGAEAuBgBAHAZAQCbGQAAqBkCAMYZAACbGgAAvRoEAFwbAACfGwAAYhwAAHgcAACCHAAAixwCAJQcAAAJHQEAbR0AAHYdAgCCHQAAQh4AAJMeAQCaHgAArx4BAAofAAAMHwAALh8AADIfAAA0HwAAbB8CAHMfAQCCHwAAtB8AAMcfAABlIAAAjyAAAA==") \ No newline at end of file diff --git a/api/search.index/470c24f117.js b/api/search.index/470c24f117.js new file mode 100644 index 00000000..ed46822e --- /dev/null +++ b/api/search.index/470c24f117.js @@ -0,0 +1 @@ +rn_("QcAAACcZKBkpGWogscEAAMMaYwEDxQAAGc9iaXPnBQsAAOwUAACYGQAArBoAAIkbAACGHQAAPB8AAFcgAAADAAAA") \ No newline at end of file diff --git a/api/search.index/4759b3d4ac.js b/api/search.index/4759b3d4ac.js new file mode 100644 index 00000000..5fdac757 --- /dev/null +++ b/api/search.index/4759b3d4ac.js @@ -0,0 +1 @@ +rn_("AULjThYAAJkaAACFGwAAIx4AABEgZQLCAACmZRkilBvXHGNsMh2QHcgeLB9iH2MgIcgAAEEeWCABAwHJAAAgpWbhsBkAAEkfAACBsccAAFQdIcMAAOMX2B4hwQAAxhMzHvMBAml06h0LAABYCwAA4xQAAHYWAACpFwAA7hkAAIgbAACNGwAAhRwAACYfAACbIAAABQAhARAAZQHBAAAT4hd0KRYqFisWLBYtFoIc+wJueFUBxAAAEEkdc28QcBBxEHIQdRj7/ZFBQQ==") \ No newline at end of file diff --git a/api/search.index/479d056de6.js b/api/search.index/479d056de6.js new file mode 100644 index 00000000..ec7bfb90 --- /dev/null +++ b/api/search.index/479d056de6.js @@ -0,0 +1 @@ +rn_("AUDjSxAAAFUUAACWHQAAUyAAABEixf3GUgECAAAVexwVMB0Wbh4T4xkXxx4VFlHBAAAOGQ8ZEBkRGZQfBQLMAAAWmyAXoiBjcPsCcHIxwgAATRBXFKYY8QEBBXPiihAAANwcAAA5HwAAGwBbA8sAAB9gYW95IcMAALsXUSAFAsAAABlfHxZMHmF3SwPHAAAeocUAABq3YW10AYIC0AAAILXBAAAeS2l2FQHDAAAVnR9w/xyXAIL9AIkKxQAAHqDEAAAeSsIAAB0A+wNhZXQFAsMAABVIHhVJHnBzocQAAFEZBQLAAAARdhsRpRhmaSsC4gAADWhkbgUCwAAAERkZE/4ccnMJAtAAACCwwgAAHkdpdisCygAAIG9jchuGwQAAHGoFAsAAAB2PIBNyHW5yKwPBAAAcac0AACCHYWVsFQLDAAAVXh8Q/Rxwc2gcBQLDAAAWXSATXR9zdSOBAscAAB+cY3S3A4L9AYkKxwAAHvUFAsQAABX0HhNGHnN1G4rOAAAfZ+dzAP4CUpYHwAAAGKjFAAAXkxlIouLf57cLAADkCwAAcA0AAPsYAAB9HAAAaR4AAEMfAACnIAAAgyEAENEA/TAgAgDEAAAesR4f5MwDAAAADQAAihAAANwcAAA5HwAAEBsA") \ No newline at end of file diff --git a/api/search.index/47b06c3acf.js b/api/search.index/47b06c3acf.js new file mode 100644 index 00000000..38418bac --- /dev/null +++ b/api/search.index/47b06c3acf.js @@ -0,0 +1 @@ +rn_("ocEAABcJFQHEAAAYwx90hh8jgQLGAAAcN2NnVQHFAAAfpSB0HBkdGdIZHx/UHyHIAABiHMcf+wJscxUBxgAAEG8ec3wcwcgAADQdBQLAAACkRhwWuhxkcgWJwQAAYuEe4h7jHuQe5R6gIGRTH1QfVR9WH1cfqCBxxwAAjh+PH5AfkR+SH4cgryBRwwAAthy3HLgcuRx5IDcBg/4AQOABzQAAIIbGAAAe7vMC/RKIAOIKEgAAGRYAAKIeAAAAIAUCwAAAG6sfYlAUURRSFFMUch3nHmNzFQLEAAAULh8YjyBjc2wc+wJucgUCwAAAdBISExIuFi8WMBYxFjIWpBQSbG8FAsAAABONGSGFFmkccnTzAP0VSAmC2wMAAA==") \ No newline at end of file diff --git a/api/search.index/47d1bd11d0.js b/api/search.index/47d1bd11d0.js new file mode 100644 index 00000000..2ac67020 --- /dev/null +++ b/api/search.index/47d1bd11d0.js @@ -0,0 +1 @@ +rn_("FQHCAAAW+x533xkricEAABi+IcMAAK8bdh4VAcIAAKRvHGLPFCUBwwAAE1QebtMe6B5nBQD9MMEOxgAAHtbEAAAgasQAABn2zAAAH6SCIRUAAA==") \ No newline at end of file diff --git a/api/search.index/47d6ac6235.js b/api/search.index/47d6ac6235.js new file mode 100644 index 00000000..569240b1 --- /dev/null +++ b/api/search.index/47d6ac6235.js @@ -0,0 +1 @@ +rn_("BQLAAAAbfyASDx1ydBuJygAAIHMrAskAACBnZXQxxwAAVR7EHskfNQHFAAAW0B93gx1rHpkfBQLCAAAYZiAafiBydfuJFYLDAAAUMh97HxOBg8cAAB+q1wECwBJixwAAHz7h3RcAAAsdAABAIcMAAF0fXx8FAsAAADWuGMMcXSAeaCBldCHIAACgG3EescQAAMUX8wD9DAAK5YwQAAAGEgAAuhoAAAUfAAA7HwAAUiAAAGAAAPuGwcMAAFcbMcMAAJkamhojHhUBxQAAFrAedmwZhwMB/RFARcEAABTgwwAAG63GAAAfhTowAAABAAAAAAAYABAAAAA8CVARURFSEVMRVBEJEikSBhWCF4MXghn+Gf8ZfRtrHKQcQB6CHoMetB61HtYeiB+ZIPsCb3Y=") \ No newline at end of file diff --git a/api/search.index/48299823b3.js b/api/search.index/48299823b3.js new file mode 100644 index 00000000..02a9c376 --- /dev/null +++ b/api/search.index/48299823b3.js @@ -0,0 +1 @@ +rn_("AQD9BMAJwwAAF+zGAAAew8IAAB+DzAAAIGLFAAAdB+4DCQAAVgoAAIsLAAA8EAAAMBIAAFsUAAA/FgAASRwAAG0cAACyHAAAiR4AAAMfAAAjHwAAeh8AAMMfAAAgIQABAAAAAA==") \ No newline at end of file diff --git a/api/search.index/495b788ac5.js b/api/search.index/495b788ac5.js new file mode 100644 index 00000000..08052808 --- /dev/null +++ b/api/search.index/495b788ac5.js @@ -0,0 +1 @@ +rn_("BQLAAAAXEB0S6RdpcoMA/RAQBcUAABuwyAAAHYTKAAAerjswAAABAAAnAAMAHgsAAGILIwBYFAIAKwLAAAAYfml0") \ No newline at end of file diff --git a/api/search.index/4a04ed2a5c.js b/api/search.index/4a04ed2a5c.js new file mode 100644 index 00000000..fa17e48c --- /dev/null +++ b/api/search.index/4a04ed2a5c.js @@ -0,0 +1 @@ +rn_("BYnAAAAS2h4USx8rAsEAAB0aaXTBwgAASh1lA8EAABa2HxMbHxY3H2Rpd5EbkhvUHNUc1hzuHrOF/RCBAsYAAB+G") \ No newline at end of file diff --git a/api/search.index/4a5cf2a978.js b/api/search.index/4a5cf2a978.js new file mode 100644 index 00000000..7deabe77 --- /dev/null +++ b/api/search.index/4a5cf2a978.js @@ -0,0 +1 @@ +rn_("McMAAH4XfxcvHSUCxAAAFrYeMDEcWB2aHXJzABJQH2UBwAAAGXYbdD4JzB7NHs4ezx6fIBUBzQAAEK0fc38dBQLAAAApIx0THx05H2xyAUOOMRIAAAFBji4NAADRxAAAyBcFA8YAABYDHyV+HawfGHEfZXN0+wJmchc/Af0toQfDAAAVDMcAAB8OxwAAG6HjvwcAAPwHAACRGQAAcB0AAEQAocIAAPQUBQLFAAAWMx8TWR5ob1MC/RVAAMEAABfWwQAAHiIBQx4AAPsDYXV5") \ No newline at end of file diff --git a/api/search.index/4b66865821.js b/api/search.index/4b66865821.js new file mode 100644 index 00000000..62745389 --- /dev/null +++ b/api/search.index/4b66865821.js @@ -0,0 +1 @@ +rn_("BQPEAAAWIh0XKB0XKR1iaXAFg8EAABVJHRKCHDHEAABlGWYZnh5rwAAHzAAAHlqlAsIAABSSHRa+HnN3gBkbAsMAAB0FY3SfAv0B2AjHAAAe+MAAABZNxgAAGO0=") \ No newline at end of file diff --git a/api/search.index/4b87f295f7.js b/api/search.index/4b87f295f7.js new file mode 100644 index 00000000..00c41d4b --- /dev/null +++ b/api/search.index/4b87f295f7.js @@ -0,0 +1 @@ +rn_("BQLAAABzRRlGGUcZSBnEGcUZZhzCthBpeRUBxgAAE34fbCQeBYnAAAAy+xn8GfscEdce8wABdIILCwAAscMAAH0ZMcYAAJUclhx8H9MA/RQQBsUAAByXxAAAHXH3eRYAAAEA7wTDAfIBAwCWACUBwgAAERAdbeoLJx3HAwD9IEgnxQAAGS7CAAAcLskAAB7f/PEDAAABAEcHxAFfAMgIfAGiAWAA0wHTAFUB") \ No newline at end of file diff --git a/api/search.index/4ba37fc810.js b/api/search.index/4ba37fc810.js new file mode 100644 index 00000000..5f9b12c4 --- /dev/null +++ b/api/search.index/4ba37fc810.js @@ -0,0 +1 @@ +rn_("AULk/RgAAJkcAAD3HAAAGx0AAB8dAABBIQAFAsAAAB9BHx98H2VsBQLAAAAfeh8TPRZheRcDAMAj0NAAAB97ygAAHTLKAAAdM+Z8BAAAsQgAAIYQAADjEQAAURkAAFEeAABTHgAAEQAQAA==") \ No newline at end of file diff --git a/api/search.index/4c8c036872.js b/api/search.index/4c8c036872.js new file mode 100644 index 00000000..eb0ee1dc --- /dev/null +++ b/api/search.index/4c8c036872.js @@ -0,0 +1 @@ +rn_("BQLAAAAeaR8V4RhocqWBxQAAEV0ctRcFAsUAABhaHxUeHWRyCQLFAAAgo9AAACBxb3JRxQAAuxy8HL0cvhx6IFHEAAA5HDocOxw8HGwgBQLAAAAWYiAanB50dZc9AP7AQnQDzQAAH2jGAAAbYsQAABlE81MZAABJBPEBGwLCAAAeIWVt") \ No newline at end of file diff --git a/api/search.index/4d6558291d.js b/api/search.index/4d6558291d.js new file mode 100644 index 00000000..1a006197 --- /dev/null +++ b/api/search.index/4d6558291d.js @@ -0,0 +1 @@ +rn_("McMAAP0eGR9cIBUBwQAAqYIbcmEKNQHEAAASXB5r6BmEH5gfI4ICxgAAHohzdLHBAABqFAGCAtEAACCcxgAAHodlc1cBAP3AIQLHAAAdE8IAABiA9icLAACNAAEAqAhUBO8FocUAAEkcEwICxQAAH1tmdOQTGQAA9R4AAH8fAACVHwAAriAAADAQAPOBAml524/DAAAM/g==") \ No newline at end of file diff --git a/api/search.index/4db0972d82.js b/api/search.index/4db0972d82.js new file mode 100644 index 00000000..7f9b6b9c --- /dev/null +++ b/api/search.index/4db0972d82.js @@ -0,0 +1 @@ +rn_("ocUAAJscBYnAAAAS2h4USx8rAsEAAB0aaXTBwgAASh1lA8AAABa2HxMbHxY3H2Rpd5EbkhvUHNUc1hzuHisC4AAAGSVkcrOD/RCBAsYAAB+GBQLDAAAULR0hpxlmIGlsGwLEAAAZ9WNkuwTDAAAetmFwcXM=") \ No newline at end of file diff --git a/api/search.index/4de9244bcf.js b/api/search.index/4de9244bcf.js new file mode 100644 index 00000000..06366258 --- /dev/null +++ b/api/search.index/4de9244bcf.js @@ -0,0 +1 @@ +rn_("AUg7MAAAAQAA7QACALYbdgCoHXYAQwADwwAAGJHFAAAZrmNvc+UZCQAAmBAAAEsRAAA6EwAAhh4AAM4fAABEAAA=") \ No newline at end of file diff --git a/api/search.index/4e02852269.js b/api/search.index/4e02852269.js new file mode 100644 index 00000000..aa3b479e --- /dev/null +++ b/api/search.index/4e02852269.js @@ -0,0 +1 @@ +rn_("ocYAAPsYQwD9ABUJwgAAEEbCAAAQR8QAAB+NxgAAHE7szQcAAJoIAABiEAAA4RQAAPkUAACdFwAAtxgAAIwZAADeGQAAAxoAAHwbAACtGwAAAh8AAIIQEQAAAAA=") \ No newline at end of file diff --git a/api/search.index/4ec3b33fc8.js b/api/search.index/4ec3b33fc8.js new file mode 100644 index 00000000..dd091e44 --- /dev/null +++ b/api/search.index/4ec3b33fc8.js @@ -0,0 +1 @@ +rn_("ocMAALQYQcYAAKocqxysHGQgBQLAAAAUrhsVMB9hbPMDwALA1JsfAAAHB/XzAAFkOjAAAAEAAAAAAB8AEAAAAEoUZhRnFJ8UoBShFAwVOhZgFmEWYhaBF8UXxhfHF+wXkRiYGJkYmxmbGlwbdh13HXgdkx6UHpoeCh8uH4IfjyABQzswAAABAACKAAIAsxgAAA8aiQChxQAApRxF/cMDgAIAACU7H1IgM8EeyB9fICOBHcIeFTwfyR4QH3UghCAxxAAAfRukHIgf+wJjdfMAAmFwgwwNAAD7A2RpdA==") \ No newline at end of file diff --git a/api/search.index/4f89357065.js b/api/search.index/4f89357065.js new file mode 100644 index 00000000..7469c187 --- /dev/null +++ b/api/search.index/4f89357065.js @@ -0,0 +1 @@ +rn_("IcAAAMsHmQgxwAAAPA2BHGIgIcAAAOELrxlFAcAAABhXIGaTF4EY4hjmGSHCAACVGcYescEAALsZ8wEDYWtz8xYJAAD1CNUGBYnBAAAy+xn8GfscEdcescMAAH0ZMcYAAJUclhx8H9MC/RQQAsUAAByX93kWAAABAO8EwwHyAQMAlgD7Am1vxwoA/kBEkEnDAAAWIcQAABi7xgAAGdPOAAAfcPNhCwAALQBdDQ==") \ No newline at end of file diff --git a/api/search.index/50583b1d13.js b/api/search.index/50583b1d13.js new file mode 100644 index 00000000..d953c09c --- /dev/null +++ b/api/search.index/50583b1d13.js @@ -0,0 +1 @@ +rn_("AUDmAg0AAIMYAAB+HgAAyh4AAAgfAACNIAAAmCAAABAwAADBxAAAYx6hxQAAlhkHFgH9NaEDxwAAIKbDAAAfCsMAAB+fxQAAIGnCAAAaucEAABIN/yALAAA8CTEEAQATAgEADALgAXsAUAALABUAEQDoAEUA") \ No newline at end of file diff --git a/api/search.index/507cc0e2f9.js b/api/search.index/507cc0e2f9.js new file mode 100644 index 00000000..c35c2fd3 --- /dev/null +++ b/api/search.index/507cc0e2f9.js @@ -0,0 +1 @@ +rn_("ocgAANEexQHDAAAX+h5ynRojgwLHAAAexWVsBQLAAAARxxMVtx5hctMAwARKxwAAHK3zSAoAAAsRngHzAIIBLRwAAA==") \ No newline at end of file diff --git a/api/search.index/5080dbd0c8.js b/api/search.index/5080dbd0c8.js new file mode 100644 index 00000000..53539f65 --- /dev/null +++ b/api/search.index/5080dbd0c8.js @@ -0,0 +1 @@ +rn_("BYnBAAASoCAUqCAjjQLGAAAgr3BzpQHEAACmah93jhwTgQLIAAAflm5yBQLEAAAapCAYmiBkcwUCxAAAHbcgF40gY2Ub/RABDMkAACB7zAAAIJnIAAAgbQUCwwAAFGwgFXogaW1DgcAGQM8AACC0zAAAIJ8FAsAAABTnHhp5IGVsBQLAAAAWlR8blyBidiuFyAAAIGsVAcAAABeMIGKZHvMCAXMBPx4AACsCxQAAH5RtdAUCwQAAFJYgGK4gaWwjhwLHAAAgp2RlAYQC0wAAILjHAAAglW9zV/aC/kBbqgnGAAAfWMcAAB+TxAAAHubMAAAgmAUCwAAAH4sgFPEcbHJXBQD9TFILxQAAHVvKAAAfxMwAACBuxAAAHPLMAAAgWeuTCwAAqRQAAAsVAADNGQAA6hkAAIIbAACLGwAAgxwAACMdAACeHQAAEx8AAIsgAAADAAERAwA1gcAAABHEHz0JtRq2GgUCwAAAEZsYURoZaxtlHHMgfyBlbiWEwwAAFJ4feR2dIEMAwgRDxAAAGKfNAAAfmM4AAB/FxQAAHvsB3xkAALHCAAC/FAUCzQAAFpsgF6IgY3AFAsAAABxgHxKmGG95IcMAALsXUSAFAsAAABlfHxZMHmF3KwLHAAAeoWF0AYIC0AAAILXBAAAeS2l2FQHDAAAVnR9w/xyTgv0AiQrFAAAeoMQAAB5KwgAAHQD7A2FldAUCwwAAFUgeFUkecHOhxAAAURkFAsEAABF2GxGlGGZpCQLQAAAgsMIAAB5HaXYrAsoAACBvY3IbhsEAABxqBQLAAAAdjyATch1ucisDwQAAHGnNAAAgh2FlbBUCwwAAFV4fEP0ccHNoHAUCwwAAFl0gE10fc3UjgQLHAAAfnGN0twGC/QGJCscAAB71wwAAHP4FAsUAABX0HhNGHnN18wL9SBkOhOQLAAD3CgD9GUFSwQAAGpzFAAAf0PVOEAAAAQA0DegALgE=") \ No newline at end of file diff --git a/api/search.index/509ec3532f.js b/api/search.index/509ec3532f.js new file mode 100644 index 00000000..f6efc8fb --- /dev/null +++ b/api/search.index/509ec3532f.js @@ -0,0 +1 @@ +rn_("AUPk/RgAAJkcAAD3HAAAGx0AAB8dAABBIQBBwAAAbA2zHLQc7R7zAAFzOzAAAAEAACIABACWCx0AJhYAAEQcAQBZHAEAW8AEhMEAABuV") \ No newline at end of file diff --git a/api/search.index/5238d9f275.js b/api/search.index/5238d9f275.js new file mode 100644 index 00000000..a3998391 --- /dev/null +++ b/api/search.index/5238d9f275.js @@ -0,0 +1 @@ +rn_("sckAAKUeFYHEAAAR8BxLHwUCwAAAEdoeETYecnT7A2FvciHCAABoGxodBQLAAAAY/BwuXiCxIHJ4BQLFAACzoh61KR9wc3cEAP0YMR3KAAAfLMkAAB73ywAAH2LEAAAZwsEAABZfAdILAAAFAsAAALO/FBXBGXJ1wcIAAEodBcDEAEoAABrCHxfgHhRaHYcpAP2W8QbAAAAL48gAABzvGotaNzfJAAAgVswAAB8leZTYms3CAAAcSOanBgAA+goAAGkQAAD2EQAA3RQAAE8ZAAAwHQAAARMAAA==") \ No newline at end of file diff --git a/api/search.index/523fe2bb5f.js b/api/search.index/523fe2bb5f.js new file mode 100644 index 00000000..205a608c --- /dev/null +++ b/api/search.index/523fe2bb5f.js @@ -0,0 +1 @@ +rn_("BYnBAAASoCAUqCAjjQLGAAAgr3BzpQHEAACmah93jhwTgQLIAAAflm5yBQLEAAAapCAYmiBkcwUCxAAAHbcgF40gY2Ub/RABDMkAACB7zAAAIJnIAAAgbQUCwwAAFGwgFXogaW1DgcAGQM8AACC0zAAAIJ8FAsAAABTnHhp5IGVsBQLAAAAWlR8blyBidiuFyAAAIGsVAcAAABeMIGKZHvMCAXMBPx4AACsCxQAAH5RtdAUCwQAAFJYgGK4gaWwjhwLHAAAgp2RlAYQC0wAAILjHAAAglW9zV/aC/kBbqgnGAAAfWMcAAB+TxAAAHubMAAAgmAUCwAAAH4sgFPEcbHJXBQD9TFILxQAAHVvKAAAfxMwAACBuxAAAHPLMAAAgWeapFAAACxUAAM0ZAADqGQAAghsAAIsbAACDHAAAMAAREAUCwAAAEZsYURoZaxtlHHMgfyBlblMAA8UAAB/QaXd584MdAADoAC4B") \ No newline at end of file diff --git a/api/search.index/529d839a0c.js b/api/search.index/529d839a0c.js new file mode 100644 index 00000000..419f330d --- /dev/null +++ b/api/search.index/529d839a0c.js @@ -0,0 +1 @@ +rn_("BYnAAAAZMBwVMR0FAsAAABSLHRVeHnFz8wCCAbcNAADzAAFy7CgNAAAnEgAABBUAAI8XAAChFwAA2BgAAHMZAADjGQAAdhsAAJUbAAC2HAAAbh4AAHkgAAABAAAAADAABQLBAAAZoh0VnBpxdBWCwwAAEXkesh4BAJDJAAAfuOtDCgAARgsAABQSAAAvEgAASxQAANcUAABbGQAAchkAABQfAABNHwAAUh8AAM0fAABAEAIAAAB7wBAlxgAAHv8FAsAAABESGRvzHnN2+/0BQQE=") \ No newline at end of file diff --git a/api/search.index/52accef948.js b/api/search.index/52accef948.js new file mode 100644 index 00000000..6acd89b5 --- /dev/null +++ b/api/search.index/52accef948.js @@ -0,0 +1 @@ +rn_("McMAAPMUixiMGBWBxgAAEv4eih0jBALEAAAdGWh38p8XAABbBaHEAACdF9MC/TAADsUAAB8lwQAAFRLjKgsAAMELAACfHAAAsiAAAEEAS43EAAAeTMYAAB1c") \ No newline at end of file diff --git a/api/search.index/52c5fa570f.js b/api/search.index/52c5fa570f.js new file mode 100644 index 00000000..66bd71de --- /dev/null +++ b/api/search.index/52c5fa570f.js @@ -0,0 +1 @@ +rn_("0cEAAHYNccIAAKkgqiCrIKwgrSC2ILggAQEBwgAAF7du5NULAADIEwAACRUAAOAZAADzGQAAAgEQ+/wAcAFC468UAADOGQAA6RwAAGcgAABAAAUCwQAAFiseGEEfYWMhwwAAYB2uH7HDAABzFqHCAAAnEisC7gAAEdRkbgUCwAAAFVwfHJAgYWj3BQD9BGEb4AAAHkfFAAAdFekyCQAA6xQAAIUYAADYGQAAqRwAAEseAAD0HgAABh8AAD0fAACqHwAAIBAAAAAVA8AAAKlkHRfPHBOgF2FkdHUNWwPDAAAaA2RlchUBwQAAMaMdOB9vH20oHfuH") \ No newline at end of file diff --git a/api/search.index/5350a33320.js b/api/search.index/5350a33320.js new file mode 100644 index 00000000..042c13d3 --- /dev/null +++ b/api/search.index/5350a33320.js @@ -0,0 +1 @@ +rn_("BQLBAAAVSB4VSR5wczHEAABkELcaZx8VAcUAABZhIHI/H0UBwwAAFtIfd6wXrReOHdke+wJhZKUCxAAAFFwbRuwc7RzuHKAfY3NGEccDAP1EoQHAAAAUvsUAAB6xgv0KAAA=") \ No newline at end of file diff --git a/api/search.index/53a9a669d7.js b/api/search.index/53a9a669d7.js new file mode 100644 index 00000000..35675a54 --- /dev/null +++ b/api/search.index/53a9a669d7.js @@ -0,0 +1 @@ +rn_("YcEAAGkQahD2EfcR+BH5EQECAsIAAB0SwwAAH4xiaeshDQAA4hQAAL8YAAB+HAAAmxwAAFcdAABeHgAAuB4AALseAAD6HgAAZR8AAM8fAAAQABAAEBBrwAAlwwAAHPY=") \ No newline at end of file diff --git a/api/search.index/544f5e8295.js b/api/search.index/544f5e8295.js new file mode 100644 index 00000000..d96c36fd --- /dev/null +++ b/api/search.index/544f5e8295.js @@ -0,0 +1 @@ +rn_("BQLAAAATrxoYrh9jZQUCwAAAF4EfKq8eNB9haBcCAP0cQQLDAAAe88YAAB9zwQAAF5LCAAAZmeN5EAAACRkAAEAfAACTHwAAEwBDAAPBAAAeauAAACBpaW1z6AYNAAA0FgAAIRkAAEAZAADsGQAAcBsAAGAfAACWHwAAWSAAABQwAwAAGwLiAAANmmV5") \ No newline at end of file diff --git a/api/search.index/54be3c6103.js b/api/search.index/54be3c6103.js new file mode 100644 index 00000000..7ff58583 --- /dev/null +++ b/api/search.index/54be3c6103.js @@ -0,0 +1 @@ +rn_("JQHEAACwKhlz8BSHHVUCxAAAscMasCcZaXPsFO0U7hTvFIYdscMAAIUbpQHGAAAW0R93iB3zhAJtdDUBwwAAFhUdd+sUhRiGGPsCcnP7A2VvdA==") \ No newline at end of file diff --git a/api/search.index/55460888ab.js b/api/search.index/55460888ab.js new file mode 100644 index 00000000..1204b5ba --- /dev/null +++ b/api/search.index/55460888ab.js @@ -0,0 +1 @@ +rn_("McIAAOIX4xfYHgWRwAAAKWMciCAR6RcFAsAAAHQSEhMSLhYvFjAWMRYyFqQUEmxv8wABY4LbAwAAIcUAANIcqx8hxQAAqRwGH6vBgMDBAAAQh9IAACCCUcAAAGoZaxlUHVUdVh0VkMAAABYHHcMf8wACZHIBuQ0AABMAAsMAAB5PZWgBdxwAAAECwsAAyAAAH9PBAAAeXMUAAB5n77ANAACwGAAA6BgAAIMZAADoGQAAehsAAKIcAABnHQAAhx4AAOweAACEHwAAmB8AALkfAAC+HwAAdiAAAHggAAAwCAEVEAAgABUBxgAAE34fbCQegwH9AEgHwgAAHC7DAAAdEMUAAB1xwwAAHGbzJBYAAFEF0wBrwAIGygAAH3ihxwAAahkBQOVJEAAA/RgAAJkcAAD3HAAAGx0AAB8dAAAEEhAVAcIAADA8DYEcYiBz6xj7Am1yFQHCAAAUWh5tRAn/L/5AZLJIxgAAHGc=") \ No newline at end of file diff --git a/api/search.index/558d563ac8.js b/api/search.index/558d563ac8.js new file mode 100644 index 00000000..0d4297f4 --- /dev/null +++ b/api/search.index/558d563ac8.js @@ -0,0 +1 @@ +rn_("AQQBxQAAHzZy5JAbAACOHwAAhyAAAKIgAACvIAAABAAAI4ECxQAAHqhjZwUCxgAAEZEepEkfbXQBQeMZEgAAQBYAAEgeAACQHgAAMwDzAoIB0BQAAPsDbG9y") \ No newline at end of file diff --git a/api/search.index/55af81025c.js b/api/search.index/55af81025c.js new file mode 100644 index 00000000..08037a89 --- /dev/null +++ b/api/search.index/55af81025c.js @@ -0,0 +1 @@ +rn_("BQLAAAAQBQ0RUBZ0eAFB5DIQAAD+EQAARRYAAHMWAABIHQAAIAIABQLAAAATvhgdpB90dSHHAABeILEg+wJkc/sCbG07i8cAAB/O") \ No newline at end of file diff --git a/api/search.index/56b20b00bd.js b/api/search.index/56b20b00bd.js new file mode 100644 index 00000000..9cb806c2 --- /dev/null +++ b/api/search.index/56b20b00bd.js @@ -0,0 +1 @@ +rn_("QcgAAIsbjBuDHIQcBYbAAAAcEh8SRhsrAsYAAB6ebHQzAP0RAQPBAAANCsEAABut4AAAHUnpoggAAPsIAABvEAAAdRgAAJwZAABWGwAAdR0AAGMfAACMHwAAnh8AAAAwAAAA") \ No newline at end of file diff --git a/api/search.index/56d76edc63.js b/api/search.index/56d76edc63.js new file mode 100644 index 00000000..0f25144c --- /dev/null +++ b/api/search.index/56d76edc63.js @@ -0,0 +1 @@ +rn_("0cEAAHYNAQEBwgAAF7du5NULAADIEwAACRUAAOAZAADzGQAAAgEQ+wJjZQFC468UAADOGQAA6RwAAGcgAABAAAUCwQAAFiseGEEfYWMhwwAAYB2uH7HDAABzFqHCAAAnEisC7gAAEdRkbgUCwAAAFVwfHJAgYWj3BQD9BGEb4AAAHkfFAAAdFekyCQAA6xQAAIUYAADYGQAAqRwAAEseAAD0HgAABh8AAD0fAACqHwAAIBAAAAAVA8AAAKlkHRfPHBOgF2FkdHUNWwPDAAAaA2RlchUBwQAAMaMdOB9vH20oHfuH") \ No newline at end of file diff --git a/api/search.index/57817a87e4.js b/api/search.index/57817a87e4.js new file mode 100644 index 00000000..f27ad811 --- /dev/null +++ b/api/search.index/57817a87e4.js @@ -0,0 +1 @@ +rn_("YcMAAGkQahD2EfcR+BH5ESWExQAAFJ4feR2dICWExAAAFGMfCB2UIPOBAmxyUcMAALsavBpjHIMgiCDzggJydBUBxgAAEKsecyAdEwICwQAAG29wdeHKHAAApR4AAEI1AcAAABfGH2RoENMZBB/zgoYxxQAABhWCF4MXvwL9NYEEwQAADUHCAAAR9coAAB0r") \ No newline at end of file diff --git a/api/search.index/5788256244.js b/api/search.index/5788256244.js new file mode 100644 index 00000000..7379ae20 --- /dev/null +++ b/api/search.index/5788256244.js @@ -0,0 +1 @@ +rn_("AUDnZhgAAD8dAAB6HgAAMx8AAFgfAAB3HwAAcCAAAJcgAAAyMAAAIwACwgAAFNppc/IcCwAAygkTgQLgAAAI+2xy") \ No newline at end of file diff --git a/api/search.index/57ad018274.js b/api/search.index/57ad018274.js new file mode 100644 index 00000000..c3dfc6a7 --- /dev/null +++ b/api/search.index/57ad018274.js @@ -0,0 +1 @@ +rn_("ocMAALgYVQLAAAAitxgCHyB5GcAZcnMJCV8KGRWxGqgcMcgAAH8fgB+uIDUBxAAAElYeY3ceeB6WIDMAA8UAAB0uaWx15ecUAAB0GQAAphsAACQdAADCHwAAViAAABAgAOvBBiDFAAAfMQ==") \ No newline at end of file diff --git a/api/search.index/57b0360136.js b/api/search.index/57b0360136.js new file mode 100644 index 00000000..ef86e031 --- /dev/null +++ b/api/search.index/57b0360136.js @@ -0,0 +1 @@ +rn_("BQLEAAARmR0S8hltcwUCwAAAHTgfs2AWaG+hwwAAeBhzAP0NAAHMAAAgd/OiGAAAugTDAEHCAABIFkkWShaEHQUCwwAAVDkcOhw7HDwcbCBVuxy8HL0cvhx6IGltBQLBAAAVaBsVzRlyc1HNAADMHs0ezh7PHp8gUdAAAK8fsB+xH7IftCBvA/0NkALFAAAZTsgAACCTscEAAK0NBQLGAAAUlCAVnSBscgEBAsAAABjhxQAAHqxlc+TjGAAA1BkAAG4dAADpHgAAvR8AABISAPvAAoT7/REBQA==") \ No newline at end of file diff --git a/api/search.index/57c086b855.js b/api/search.index/57c086b855.js new file mode 100644 index 00000000..cf7e0760 --- /dev/null +++ b/api/search.index/57c086b855.js @@ -0,0 +1 @@ +rn_("McUAAL4XXxlgGTHHAACEHoUemiA1AcUAABZnHnfoGLofvh8FAsEAAKebHEedHJ4cgCCBIHJz+wJldPsDZXR1BQPDAAAYSB8Rmh82IR1wH3IfYmVzBQLGAAAgQh+hIGIVHxYfFx8YH1ogpCBzdSuKxQAAILPRxAAAhBcFhcAAABW9FxVeGecoAP2PkRPJAAAdfMQAABirxwAAG5zDAAAZocUAAB5YwwAAF4np+QcAAPgIAACIGAAAlBkAANocAAApHgAA9x4AAKkgAAC2IAAAuCAAABAQAQQA") \ No newline at end of file diff --git a/api/search.index/58b72e35b5.js b/api/search.index/58b72e35b5.js new file mode 100644 index 00000000..be5930b5 --- /dev/null +++ b/api/search.index/58b72e35b5.js @@ -0,0 +1 @@ +rn_("FQLEAAAVaB6jnRtjbHsdEwACzAAAH6FhY+H0DAAAdg0AAAShygAAmRwFisEAAKKfGKVkG+HDAABtFgFBiMwRAAChxQAASRwVgsQAABQuHkUeIcYAAMoejSD7AnBzMc4AALoesCC1INcLAP014QTMAAAgt8MAABz8xQAAH1s6MAAAAQAAAAAAIQAQAAAADAkxCUgWSRZKFowX1xfYF9kX2hfbF5sYExkUGRUZFhmrGqccsRy7HLwcvRy+HIQdIB5dHvUeTh9/H4AflR96IIogriA1AsAAAMQ7GRYBH2V39xn4GV8cAUHo/QgAAD4KAAD1EQAAEhIAAC4WAABwGQAAvRoAAJ8bAABCHgAAFAFBQAAxwAAAKw0sDTUQ1w0AwwTlwQAAClfgAAAH7oPhBwAA") \ No newline at end of file diff --git a/api/search.index/58c6fb1b6f.js b/api/search.index/58c6fb1b6f.js new file mode 100644 index 00000000..0d8227dc --- /dev/null +++ b/api/search.index/58c6fb1b6f.js @@ -0,0 +1 @@ +rn_("occAALQeIwD9MAEFyQAAIHHBAAAU/8UAAB82xwAAH8PtGQ0AAJ0NAADtGAAAnxkAAGQbAACQGwAANxwAAIAcAAApHQAAhh8AAI4fAACHIAAAoiAAAK8gAAAxABAAAEAA") \ No newline at end of file diff --git a/api/search.index/58d1bdacde.js b/api/search.index/58d1bdacde.js new file mode 100644 index 00000000..30a7c893 --- /dev/null +++ b/api/search.index/58d1bdacde.js @@ -0,0 +1 @@ +rn_("BQLAAAAT2BwfiiBhcksDxAAAHU7HAAAfHmVsdAUCwQAAGE0fGU4fY3AFAsEAABq5Hx12IGR0NwgA/kAHYgnBAAAUzMcAAB7bwgAAFoTCAAAWhcMAABxXywAAH7r1OAkAANEBAQAXC9kCFYTEAAAT6xmHGCsDxwAAHMnEAAAZLWltdlvABMDLAAAeihUBwQAAEAkNOCEL8wACYmXzBxIAANkCHgo=") \ No newline at end of file diff --git a/api/search.index/58f781a26f.js b/api/search.index/58f781a26f.js new file mode 100644 index 00000000..7eb39b57 --- /dev/null +++ b/api/search.index/58f781a26f.js @@ -0,0 +1 @@ +rn_("JQLEAAAWth4wMRxYHZodcnMAElAfZQHAAAAZdht0PgnMHs0ezh7PHp8gFQHNAAAQrR9zfx0FwMAEIQAAHDkeKSMdEx8dOR8BQ44xEgAAAUGOLg0AAAUDwAAAHmkfFwodFeEYaG9ypYHFAAARXRy1FwUCxQAAGFofFR4dZHIJAsUAACCj0AAAIHFvclHFAAC7HLwcvRy+HHogMcQAAAgVrBitGFHEAAA5HDocOxw8HGwgBQLAAAAUWyAkkB1jIG1uocUAAOkY+wJobwUCwAAAFmIgGpwedHVX9wD+wEN2A80AAB9oxgAAG2LEAAAZRO0NCwAAthQAAC0ZAABBGQAAUxkAAE8cAADSHAAA2xwAAGYdAACcHQAArh4AAI0fAACrHwAAwB8AAJYAAQAAAAChxQAAARqlg8AAABLaGRZRHs0UBQLCAAAVqh4UUB5jcwWKwAAAGPkeGwIfpQLDAAAUkh0Wvh5zd4AZGwLHAAAe+GFs8wPCFkDh7BEAAMcZAAAQAUHjcBgAAEsZAACWGQAALx4AAEET8wACZW4B9AcAANHEAADIFwUDxgAAFgMfJX4drB8YcR9lc3T7AmZyAQD9P7EHVI7kOoAUYlyYc8MAABUMxwAAHw5TsEnczccAABuhM1Zx1I/CAAAeIUp/5IuOURapxXYZS07CERAbho4magKCVTnjvwcAAPwHAACRGQAAcB0AAEQA") \ No newline at end of file diff --git a/api/search.index/59008ab283.js b/api/search.index/59008ab283.js new file mode 100644 index 00000000..e7481d97 --- /dev/null +++ b/api/search.index/59008ab283.js @@ -0,0 +1 @@ +rn_("FYLAAAASdyBDCQUCwAAAcr8XwBfBF8IXwxfEF5wfE2UUa3BrA8YAAB/DaG9yBQLAAAAVqR4WLxxlbAUDwAAAq0UfMOUY5hjXGROYGWxydTUBwQAAFgEfd/cZ+BlfHKHKAABtH/OFAmNlBQLAAAAoQR5YILKFG295ocYAAPkZBQLAAAAhoRg+HiZmHcAfZXQXAQD9EIAOxQAAHPbMAAAfx8MAAB2P4akNAABqGAAAIKUCxQAAFmEeFmIebXfIGaUBxgAAFtEfd4gdBQLAAAAVBR8RBBVhZgFA4bQUAABLGwAAFvsCZG4FAsAAACNxHC4eGp8fbnAFAsAAAKV7GRWfHmhv9x4B/gBFLAvJAAAfZDowAAABAAAAAAAaABAAAAA8C9YL1wvYC9kLJxYoFncWrhjdGaMaSRuiG6MbZxzDHCEdTh2VHUQeRR6cHh0fJB9wH3IfXSAhwQAAChWoHsv+AAkYCMEAABA+xQAAGYzAAAAU/6HFAABjFKHGAAC0HgUCwgAAFjEfFy4dY2T7AmFyBQLAAAAUxx4axx9scAUCwwAAE64fGCseb3IhwwAAoRD2GV8B/QGAB8gAACBxwwAAHx0BQuG3CwAA+xgAAIEBAP7AffBDLf2TwLXDAAAQN8IAABqvejQdZ8NjoieA88MAABlvV9o9ENpv+TezF1pYCBDoQVdJi9PDAAAZP8UAAB82xQAAHx0SqdrOZcYAABeZOjAAAAEAAAAAABsAEAAAAIoL6AsZDRoNGw0cDZ0Nng3tGJ8ZwhnDGWQbZRuQGzccgBwpHYMfhh+OH48fkB+RH5IfhyCiIK8g") \ No newline at end of file diff --git a/api/search.index/591b417346.js b/api/search.index/591b417346.js new file mode 100644 index 00000000..06ab6e7d --- /dev/null +++ b/api/search.index/591b417346.js @@ -0,0 +1 @@ +rn_("ocMAALQYQcYAAKocqxysHGQgJQLAAAAWyx4aUSBhdAgZfSAFAsEAADR3HngeliA4fx+AH64gaWwxyAAAQx9EH6cgswP9GBACygAAIFDy0RkAAFoDBQLAAAAUrhsVMB9hbPMCwALB1JsfAAAHB/UFAsAAABDMFBayGHJzBQLAAAATVBunsxpsdnv9EEEJwQAAHMjFAAAZGw==") \ No newline at end of file diff --git a/api/search.index/59ab2d4625.js b/api/search.index/59ab2d4625.js new file mode 100644 index 00000000..32b318aa --- /dev/null +++ b/api/search.index/59ab2d4625.js @@ -0,0 +1 @@ +rn_("IcMAAJQcMh8lAcMAABWNH3LhFAMaBYnBAAAy+xn8GfscEdcescMAAH0ZMcYAAJUclhx8H9ME/RQQAsUAAByX93kWAAABAO8EwwHyAQMAlgArAsUAABTkcHQhxwAAFB1gIAUCwAAAEZ8fFW0bZXLDAP0NkADDAAAfI8kAAB7GzAAAII4BIh8AAAXAwQDgAAA18RTyFIoYFZgZ2soc3wPBhCwIItmtDg==") \ No newline at end of file diff --git a/api/search.index/5a3556794e.js b/api/search.index/5a3556794e.js new file mode 100644 index 00000000..404647c6 --- /dev/null +++ b/api/search.index/5a3556794e.js @@ -0,0 +1 @@ +rn_("JYHGAAAS/h4dHYodoccAANEeVYLFAAAXZSAzHDQcNRw2HGsghwEAyCKCxgAAHlPJAAAfEs0AAB/NAZwgAADFAcMAABf6HnKdGhUCwwAAFpsgF6IgY3BHHVOCA8cAAB7FYmVsGwLFAAAdKWxyBQLCAAATdBseYCBha0HBAACdHJ4cgCCBIBUCxAAAHaMgHJEgcnSKGysCwwAAG4lvdAcBAP0VAAPAAAATx8cAAB+iwQAAHELEAAAet4L5GQAABf3AgUAIAAAWQx6luRcUWxkezB8FAsAAABUiHRzPH2F5AQD9/5kXyAAAHXoq1kbW6QwJaVvOYSBqW3zDAAAclMAAAB6LXdeAwUrEAAAdGcYAAB0oTMo+8BDHAAAcrVBZeYLyzgAAH6B/Izy/ssIAAAz9YOwm8rM6MAAAAQAAAAAAOgAQAAAAqgarBvMH3AjdCN4I3wjgCOEI4gjjCOQI5QjmCOcISApSC1MLVAvAC1sNrQ2uDa8NXRCjFPsU/BT9FEYWRxafF7wXvhjsGDAZOxk8GT0ZPhleGXcZeBnPGfsZ/BlTG0YcRxzxHPoc+xwRHUIdWh2UHTYe1B7VHg==") \ No newline at end of file diff --git a/api/search.index/5a37758675.js b/api/search.index/5a37758675.js new file mode 100644 index 00000000..b06b898b --- /dev/null +++ b/api/search.index/5a37758675.js @@ -0,0 +1 @@ +rn_("ocQAACkeFQLEAACmhRwWhxxjck8d0cYAAFYZMcMAAPYY9xinH7HJAACmGxMAAsIAABM6ZHXmvRQAAD0WAAB+FwAAkhkAAKsZAABvHAAALx0AAAARAQDzAAFzAYEbAAAVAcEAABJ+GGxvDb8D/ZFBBcYAAB9xxQAAHk9xzgAAqSCqIKsgrCCtILYguCAlhMEAABOVGxASpx0FksAAACBdEKMUUXoZDx1+HawfbyArAsoAACB/cnS7/RFAAcoAACBzAQQBwQAAHlxr+ugZAACSAQEAJwEBAEkCmAAUAN4AAgAFAsEAABZnIBWIHnJ0+wJzdFWExAAA8Usb3RkhHZUdcB9yH8v9AQgGwgAAHPLNAAAfhVMAA8AAABnyZW1085cbAAD6AAABAQKFxwAAHy/DAAAddeUlCwAAXBkAAGMbAAAyHAAAQxwAAKYdAAARAAAFA8AAABgDGqS7Hhp3IGVwdAWJwQAAEqAgFKggI4cCxgAAIK9wczHBAABVHsQeyR/zgwJlcPsCcHPBxAAAqRv7hyXAxAogAACmjRsTShsWjxuiFNkcFQHCAAAilRnGHmsLEiMAhsQAABi70mELAAAtFQHDAAAQbx5zfBwrAsYAAB0lYmahwwAAuRn7igUCwwAAVa8fsB+xH7IftCAwlxeYFx4ZZHIBQOXxCAAAaBQAAJwbAAAOHQAAOB0AAI8eAAAwABBRwwAAJR4mHiceKB6MIAFBOzAAAAEAAPYAAgA7CQAA7Av1AMcBAP0MAQPIAAAZgs0AACChOzAAAAEAABYCGADzAwAA+QwAAE0QAABWEAEAWhAAAKcQBwC2EAMAVxQAAHMUAgDzFAAAThYBAIEWAgCIFvUA4hcBAIsYAQCWGAEAphgAANcYAACZGgEAxhp6AIUbAgAjHgAA2B4AANUfegAVAcMAABA8HnMhEvv9EQgFQcYAAKUftR+GIJIgFQLDAACxuxkZVyBhc+AYKwLKAAAgbmV0BQLAAACwxBAmSRskH2Vz+8EADCHJAAAJH4ofBQLAAAASJQ0kehybHWl0Ff3EDBEAAAAbbB8UKB8T5xgXTh6yGwUCxQAAMeUY5hjXGRapHm9wocQAAMYc+wJscgXAwwoIAAAYTR4VbRwZqB5VAcMAAKAsHnOZF9Ee0h5FH0YfFQHIAAAULh9jbBz7AmVyJYHBAAARoxlEFkkeocQAAMoUBQLAAAATLxISjyBudQUCwgAAGy0fFGwbbnL7AmxvAQD+QB/lA8YAAByBQh0dZAkeMoQJpjf8IdYAyQAAHQUgt6CYd8EAAB6zZ3FhxZtxuRe26wY1nTaBxAAAHHlbgPLSPMYAABnY5vADAADDBgAAvhkAAK4aAAAqHQAA3x4AAIIgAAABEAAAFQHCAAAXzx9ygBj5/ABUAAEQNQHFAAAW0B93gx1rHpkfBQPBAAAXlh8YZiAafiBmcnWlAcMAAKZqH3eOHBsC4wAAFKRheTvEACTHAAAfCDsD4AAAHZlhb3M1AcIAABWNHnPHC8gL0x8FwMEARgAAGN4eE1UbFfoescMAADgZxQLAAAAXuB4WfCBydDQdocEAAFkc8wICaXPiphcAAEMdAABbHgAAAwArAuQAABZUZHQVAcUAABa3HmRTGycaAv1dMQHGAAAdUcQAABtUwwAAGN7GAAAdUsYAAB1T5hINAAB9DQAABBoAAJ0aAAClGgAAXRsAAJUeAABhMzMw+4wVAcIAACCRHJEdc7keJQHCAAAW7RlywhBYHAFBOzAAAAEAAPYAAgBECwAAPA/1AEHDAAB1GXYZURxSHBMAjMYAAB6G5TILAABeFAAA5BQAABgZAABBHAAAyRwAACIAAPsCZ3QBRuHcHAAAOR8AALAVAcAAABatHnKHHyMAAsUAAB19cnPhahQAABwfAAAg+8AEhgWMwAAAJSMdEx8XEx0FAsQAABqkIBiaIGRzBQLAAAATPh8cmSBhcgUCwwAAHbcgF40gY2UbAsQAAB6HcnRb/RABDMkAACB7yAAAIG0FAsMAABRsIBV6IGltQ4HABkDPAAAgtMwAACCfBQLAAAAU5x4aeSBlbAUCwAAAFpUfG5cgYnYrhcgAACBrFQHAAAAXjCBimR7zAgFzAT8eAAArAsUAAB+UbXQFAsEAABSWIBiuIGlsI4cCxwAAIKdkZQGEAtMAACC4xwAAIJVvcwGC/kBfqgkTUXSL0sYAAB9YNr8YgCLRAAAgnMcAAB+TxAAAHuZgqRx9vk0j2JzdzAAAIJgh+zLX71gj5sWYaOVfaRpawEfXfQFCOzAAAAEAAPYAAgBDCwAARg71ABUBwwAAE4QbZJUXBQLFAAAUhh0Uhx1vdBWEwgAAoUQclBc1AcAAABBwHnM7FjwWpB37AmR0AUTjcBkAAL0aAACfGwAAQh4AABQAGwLDAAAZcmxvdwEB/QSRCMoAAB5b5W0NAAAGGQAAOx0AAEwfAAC4HwAAyh8AABEQAfsCaXIVAsUAABNQHRbdHm1zNxklgsEAALSLHKUXuhy1AcAAABYdHXf7FPOBkhuJwAAAFlMFjMAAABu+HxTbHAUDwAAAGCEfG7wfG70fbHR1BQLCAAAXux8UIB9zdBOBAswAACB4ZHTbBMkAAB9SYWNzdhUBwQAAFNocdlIW+wJldFv9EAQUwwAAGa/NAAAfvwXAwwBKAAAaViAXHx8UNh5LwAKKyQAAHtzLAAAfi88AACB3G4rAAAAZjAUCwQAAG1EfFFIbbHYV/sJAQEgCAAAZUB8TWBwaih8U2RwVTx1RFmsDwgAAGJVhbHIFAsAAABPYHB+KIGFySwPEAAAdTscAAB8eZWx0BQLBAAAYTR8ZTh9jcAUCwQAAGrkfHXYgZHQ3AYL+QAVACccAAB7bwwAAHFfLAAAfuqHKAAAbHaHDAAD5FGvFACTRAAAghNAAACB1BQLCAAAbTB8aHR9hbQUCwQAAHbgfJpQb1xxmdAGEAs8AACCeyAAAH7dhczsDyAAAHjVhb3kFAsMAADLcGN0YxR4biB9lZwUCwwAAExwfFYcfYnMjgwLEAAAeNG9yAQD9X/gHNSym9yAQE1UTlG8OJW8GLuuefuFnf49+TTRz1BX2zAAAH0/JAAAeksIAABZQzQAAH4lXljaWXgmw5YXHKvJIaCZq5/G+PeroCgAAWBAAAHgQAAB3FAAA6RQAAJcXAACEGAAAHhkAAMYcAAAnHwAAmh8AADABEQAQAKHDAABGHFHEAACvH7AfsR+yH7Qg8wACYWTnnRAAABkSAABAFgAAmhcAAFUZAABIHgAAkB4AAKweAAAzMQAAwcEAAFcbPwH9CaAhxAAAHwPJAAAfCsAAABBeFYLGAAARJB2qGAUCwgAAEZkdEvIZbXOxxQAAdxs7wAJBwwAAHVv7ijUBwwAAFmQgc5sfqR+eIPMCAWTjnxQAAGAWAACaHgAAgh8AACIASwPDAAAYk8MAABTjbW51BQLBAAAdiyA15xfoFzUeYXIFAsIAABk4HxWqH2lzFYLDAAAUMh97H/uDe/2ASAHDAAAc8RUBwgAAqYIbcmEKAQQBxQAAHwBy4/AZAABTHwAAfR8AAKggAAAUAAECAcUAAB5fcuLaGAAA4R4AAKAgAAAUAPuJKwPFAAAbk9AAACCJbnR1BYnAAAAS2h4USx8rAsEAAB0aaXTBwgAASh1lA8EAABa2HxMbHxY3H2Rpd5EbkhvUHNUc1hzuHrOF/RCBAsYAAB+GQc4AAKUftR+GIJIgAQYBxQAAHzZy5JAbAACOHwAAhyAAAKIgAACvIAAABAAABQLGAAARkR6kSR9tdPsCb3KxyAAAiBwFAskAABSUIBWdIGxyFQHCAAAbiCB00xwlAcYAABbSH3eOHdkeBQLCAAAWtB8bkyBjcqHGAACMHbcDAv1EEQbMAAAgg+GjFwAAMxkAABMriccAAB6QMcMAAKIXMhlsHgUCwQAAFIsdFV4ecXPzAwFy4qEXAAC2HAAAeSAAAAMAQcQAANkY0RxIH4kfNQLFAAARMx4T2B5pdO4Z7xmbICuJyQAAHl3zAv4ARfUB9DkWAAA1BcEAGAFbA88AAB+DYWVyFQHIAAAWth5yUB+lgcAAABHEH7Ua8wMBc4JOEAAApQLCAAAZjx4SlBhtdXgYe/0GCAjBAAAcXAEA/sD//w98h701Cw545gvyZE6yjhE3l9s+w3v1TwiVZDs/VuJmbeN1+xmB5AUjNHLAxbTKAAAfxCYeM2HvcD9dvqde7/Z2c1TfNmvYLt9eOtg6Dg4bJyK/zgQzWJki7kZ4savr/zkCk0YVzAAAIFlfVOxrsTswAAABAADzAFYAVAEAAGYBAAD7A3YAoAYGAMsHAADtBwAA7wcAAAEIAACZCAAAIAkAAB8LAAAnCwAANQsAAIYLAQCTCwAAtAsBAM0LBADjCwAACw0AAD0NAgCaDQIAoQ0AAP8RAAAKEgAAXRQAAKQUAwCpFAMA0BQAAAsVAAAZFgAAJRYAAFEWAACcFwAA3RcEAJ4YAACiGAAAsRgBAN4YAAAEGQAAHxkAACYZAACVGQAAmRkAAJ4ZAADNGQAA6hkAAIIbAQCLGwEAmBsAAIMcAQCTHAAAnRwBAKUcAQD1HAAAAR0BAAsdAAASHQAAIx0AAC0dAABbHQEAYB0AAJ4dAwAfHgAAIR4AAFweAACgHgAAoh4CALMeAAC5HgAAxh4AAO8eAAALHwAAEx8AABofAACBHwAArh8AALMfAAC2HwAAvB8AAMUfAABeIAAAaCAAAIAgAQCLIAAApiAAALEgAAA=") \ No newline at end of file diff --git a/api/search.index/5afcd9d74c.js b/api/search.index/5afcd9d74c.js new file mode 100644 index 00000000..d0a0b828 --- /dev/null +++ b/api/search.index/5afcd9d74c.js @@ -0,0 +1 @@ +rn_("McUAAL4XXxlgGTHHAACEHoUemiA1AcUAABZnHnfoGLofvh8FAsEAAKebHEedHJ4cgCCBIHJz+wJldPsDZXR1BQPDAAAYSB8Rmh82IR1wH3IfYmVzBQLGAAAgQh+hIGIVHxYfFx8YH1ogpCBzdSuKxQAAILPRxAAAhBcFhcAAABW9FxVeGecoAP2PkRPJAAAdfMQAABirxwAAG5zDAAAZocUAAB5YwwAAF4np+QcAAPgIAACIGAAAlBkAANocAAApHgAA9x4AAKkgAAC2IAAAuCAAABAQAQQAIwADxAAAIHTKAAAebGNvcNSlHwAAENEM") \ No newline at end of file diff --git a/api/search.index/5afe2add23.js b/api/search.index/5afe2add23.js new file mode 100644 index 00000000..e9af2232 --- /dev/null +++ b/api/search.index/5afe2add23.js @@ -0,0 +1 @@ +rn_("UcIAALscvBy9HL4ceiAFAsMAAMeVHhSLH3BxEwACwwAAHt5maQHzCgAA+4YFAsEAABRbICSQHWMgbW7zBQFj8kEZAABtBQUCwAAAET4eFsAfZXQbAsMAAB2PZXQThALIAAAgWHN0KwLHAAAePW5zGwLDAAAgk2V0IcYAAMgeYh8XBgH+AEMiCdEAAB+mwwAAH57IAAAfIssAAB8k5CQJAADxCgAAeR0AAIUfAACdIAAAMQAAa8EEgMAAABij") \ No newline at end of file diff --git a/api/search.index/5be7aaac03.js b/api/search.index/5be7aaac03.js new file mode 100644 index 00000000..8c75d381 --- /dev/null +++ b/api/search.index/5be7aaac03.js @@ -0,0 +1 @@ +rn_("BcjAggAAABNDGRhAHqdFHwmDwQAAGJzQAAAghSHAAAAgDaYNJwMB/gBDIgnMAAAfWcgAAB6bzAAAH5fOAAAgWgHTCwAAFQHCAAAXoBxwTRn7AnV2") \ No newline at end of file diff --git a/api/search.index/5c1a855da7.js b/api/search.index/5c1a855da7.js new file mode 100644 index 00000000..38fbb1f1 --- /dev/null +++ b/api/search.index/5c1a855da7.js @@ -0,0 +1 @@ +rn_("FQLEAACmhRwWhxxjck8dMcMAAPYY9xinH/sCaXIhwwAAXRCjFHHFAAB6G3sbohyjHOwediB4IDWExAAA8UsbIR1wH3Ifa8AAwc0AAB+FWwPAAAAZ8mVtdCXAxAogAACmjRsTShsWjxuiFNkcFQHGAAAQbx5zfBwhyAAASRskHyHKAAAJH4ofBQLAAAAZCB8QmR1hcwXAwQBGAAAY3h4TVRsV+h6xwwAAOBnFAsAAABe4HhZ8IHJ0NB2hwQAAWRzzAgJpc+KmFwAAQx0AAFseAAADACsC5AAAFlRkdBUBxQAAFrceZFMbJxoD/V0xAcYAAB1RxAAAG1TDAAAY3sYAAB1SxgAAHVPmEg0AAH0NAAAEGgAAnRoAAKUaAABdGwAAlR4AAGEzMzAlAcIAABbtGXLCEFgcQcMAAHUZdhlRHFIcEwGMxgAAHoblMgsAAF4UAADkFAAAGBkAAEEcAADJHAAAIgAA+wJudCHGAAAjHRMfFQLFAAATUB0W3R5tczcZJYLBAAC0ixylF7octQHAAAAWHR13+xTzgZIbicAAABZTBYzAAAAbvh8U2xwFA8AAABghHxu8Hxu9H2x0dQUCwgAAF7sfFCAfc3QTgQLMAAAgeGR02wTJAAAfUmFjc3YVAcEAABTaHHZSFvsCZXRb/RAEFMMAABmvzQAAH78FwMMASgAAGlYgFx8fFDYeQ4HAAorJAAAe3MsAAB+LzwAAIHcFAsEAABtRHxRSG2x2Ff7CQEBIAgAAGVAfE1gcGoofFNkcFU8dURZrA8IAABiVYWxyBQLAAAAT2BwfiiBhcksDxAAAHU7HAAAfHmVsdAUCwQAAGE0fGU4fY3AFAsEAABq5Hx12IGR0NwGC/kAFQAnHAAAe28MAABxXywAAH7qhygAAGx2hwwAA+RRrxQAk0QAAIITQAAAgdQUCwgAAG0wfGh0fYW0FAsEAAB24HyaUG9ccZnQBhALPAAAgnsgAAB+3YXM7A8gAAB41YW95BQLDAAAy3BjdGMUeG4gfZWcFAsMAABMcHxWHH2JzI4MCxAAAHjRvcgEA/V/4BzUspvcgEBNVE5RvDiVvBi7rnn7hZ3+Pfk00c9QV9swAAB9PyQAAHpLCAAAWUM0AAB+JV5Y2ll4/38JcSCrySGgmaufxvj3q6AoAAFgQAAB4EAAAdxQAAOkUAACXFwAAhBgAAB4ZAADGHAAAJx8AAJofAAAwAREAEAChwwAARhxRxAAArx+wH7Efsh+0IPMAAmFk550QAAAZEgAAQBYAAJoXAABVGQAASB4AAJAeAACsHgAAMzEAAFvABCjJAAAfCrHHAAB3GzUBwwAAFmQgc5sfqR+eIPMCAWTinxQAAJoeAACCHwAAIAAjgQLDAAAYk211AQQBxQAAHwBy4/AZAABTHwAAfR8AAKggAAAUAAECAcUAAB5fcuLaGAAA4R4AAKAgAAAUAPuJKwPFAAAbk9AAACCJbnR1BYnAAAAS2h4USx8rAsEAAB0aaXTBwgAASh1lA8EAABa2HxMbHxY3H2Rpd5EbkhvUHNUc1hzuHrOF/RCBAsYAAB+GQc4AAKUftR+GIJIgAQYBxQAAHzZy5JAbAACOHwAAhyAAAKIgAACvIAAABAAABQLGAAARkR6kSR9tdPsCb3KxyAAAiBwFAskAABSUIBWdIGxyFQHCAAAbiCB00xwlAcYAABbSH3eOHdkeBQLCAAAWtB8bkyBjcqHGAACMHbcDAv1EEQbMAAAgg+GjFwAAMxkAABMriccAAB6QMcMAAKIXMhlsHgUCwQAAFIsdFV4ecXPzAwFy4qEXAAC2HAAAeSAAAAMAQcQAANkY0RxIH4kfNQLFAAARMx4T2B5pdO4Z7xmbICuJyQAAHl3zA/4ARfUB9DkWAAA1BcEAGAEVAcgAABa2HnJQHwUCwgAAGY8eEpQYbXX7AmJs8QD+wGd+AwADHyIjSUpOVldYWVpbYOigBgAAHwsAAD0NAAAKEgAA0BQAABkWAABRFgAAHxkAAKIeAABgIAAAIA==") \ No newline at end of file diff --git a/api/search.index/5c386b0113.js b/api/search.index/5c386b0113.js new file mode 100644 index 00000000..ecfe9f04 --- /dev/null +++ b/api/search.index/5c386b0113.js @@ -0,0 +1 @@ +rn_("IcMAAIEdwh4FAsAAABZwIKNkHWhvMcMAAMEeyB9fICHFAAA7H1IgdwEC/QfAAtMAACC2xAAAHzzlyR4AABAfAACvHwAAdSAAAIQgAAC0IAAAADAAGwLGAAAcqGR0") \ No newline at end of file diff --git a/api/search.index/5e22c3a20b.js b/api/search.index/5e22c3a20b.js new file mode 100644 index 00000000..04bfe99c --- /dev/null +++ b/api/search.index/5e22c3a20b.js @@ -0,0 +1 @@ +rn_("AQMBxgAAHvpy4Z0aAAClGgAAMxUCwwAAFpsgF6IgY3BHHVOC/RJIAMcAAB7FxwAAHyTBxAAAYx5DBAPHAAAgpsUAACBpYWZw/I0YAAABABMCAQAMAuABewBQAAsAJgDoAEUAO/0ASQPFAAAcoMUAAB0pwgAAH2g=") \ No newline at end of file diff --git a/api/search.index/5e9860aa1a.js b/api/search.index/5e9860aa1a.js new file mode 100644 index 00000000..a3337b4b --- /dev/null +++ b/api/search.index/5e9860aa1a.js @@ -0,0 +1 @@ +rn_("BQLEAAAUmh437B52IHggY3MrAsUAABqxaG8xxAAAZhtnGyAfUccAAHIecx50HnUelSABRuJnHQAAuR8AALsfAABQAPsCZXRh1AAAqSCqIKsgrCCtILgg84IDb3N0+wJkdKHHAADKGQX9wwGABQAAHawfGhMfHq0fFTEcocEAAEsKAUDjSxAAAFUUAACWHQAAUyAAABEi8wABc+KKEAAA3BwAADkfAAAbACHDAAAUHWAgBQPDAAAYcR4WXSAjXR9fH2RzdUvAIoDHAAAfnOAAAApK/wv9TIkJwgAAGPo=") \ No newline at end of file diff --git a/api/search.index/5ead8f1e51.js b/api/search.index/5ead8f1e51.js new file mode 100644 index 00000000..22f040b3 --- /dev/null +++ b/api/search.index/5ead8f1e51.js @@ -0,0 +1 @@ +rn_("AUM7MAAAAQAA7QEFAPkMAACIFvUA1xgAAMYaegDVH3oABcDDgGAAABKzHhV5HCV6HJsdBQLAAAASDR0RdxxpdAUCwAAAEPEeI3gcgh1ydlODA8gAAB+jZWZ0GwLGAAAdDm50McUAAP0eGR9cIPMCA2Jpc+NVCwAABR0AADceAABcHwAAEBC1AsMAAKbgGRbiGXN3+gzzAMABgfgnDQAA1QQBABoHbQLKAMYATAM=") \ No newline at end of file diff --git a/api/search.index/5f1565c98b.js b/api/search.index/5f1565c98b.js new file mode 100644 index 00000000..15262ed0 --- /dev/null +++ b/api/search.index/5f1565c98b.js @@ -0,0 +1 @@ +rn_("dQLAAAAXuB4WfCBydM0XzhfPFzQdNR02HTcdocEAAFkc8wICaXPjARIAAKYXAABDHQAAWx4AAAAwKwLkAAAWVGR0") \ No newline at end of file diff --git a/api/search.index/5fc2838e02.js b/api/search.index/5fc2838e02.js new file mode 100644 index 00000000..4dfc5ca9 --- /dev/null +++ b/api/search.index/5fc2838e02.js @@ -0,0 +1 @@ +rn_("FYLAAAAVtBsXEqHBAAAiFgX8wA8AAAAQ0xgQ1BgQ1RgQ1hgF/MD/AwAAEMkYEMoYEMsYEMwYEM0YEM4YEM8YENAYENEYENIYBfzA/gMAABDAGBDBGBDCGBDDGBDEGBDFGBDGGBDHGBDIGPv8BwBXAQEFxgAAHTHEAAAbszBmbW9z6f8MAABLDQAAuxAAABsZAABhGQAARhsAAIgcAADPHAAABx0AAC4dAAAPEAAgAAFB4UIbAACpGwAAM/sCbm8=") \ No newline at end of file diff --git a/api/search.index/5ffcd29fbe.js b/api/search.index/5ffcd29fbe.js new file mode 100644 index 00000000..366a9e6b --- /dev/null +++ b/api/search.index/5ffcd29fbe.js @@ -0,0 +1 @@ +rn_("AQABwQAACzFi7OoDAACcBgAAexAAAIUWAACQGAAAnBgAALIaAACvGwAAaRwAAKIdAAB2HgAATx8AAJcfAAACQAAAAAAA") \ No newline at end of file diff --git a/api/search.index/610d32324d.js b/api/search.index/610d32324d.js new file mode 100644 index 00000000..06302e3a --- /dev/null +++ b/api/search.index/610d32324d.js @@ -0,0 +1 @@ +rn_("cccAAKkgqiCrIKwgrSC2ILgg8wIBZufFFAAAnRkAAK8cAAByHgAAhB4AAMwfAACVIAAAmiAAAEATEAAFAsAAABuRILKBFm95dYLAAABn/hn/GWscgh6DHpkg4wqKFyoZKxksGY0ZPB4lAcUAABejIHBTGZwdFQHGAACnux5yvB8FAsAAAKEwHx93H2VtpQHDAACryh9mKxIBAAHBAAAeuWv4mBsAAPsAbgABAO0BxAASAKMAI4UCwQAAIIppeaHGAADUHvsCYnExwgAAJh32HokgW8AcAMAAABlzKwLgAAAZpGRuIcAAAHgWoRsFAsEAABIlFhXPH2FzwccAAF0bocEAAMMUAUc7MAAAAQAA9gADANcYAADGGnoA1R96ABsCwgAAEFpkdCUBwAAAFHYfdDAcpR0jBcACwMYAAB81xwAAHxHybRkAADcC9wMAwAXswgAAGdk7MAAAAQAAVQAXAH8GFgD1DAEAMhACAIgQAACiEAQA/hEAADESDQBpFAAADRUEABQWAABFFgAAZRYFAG0WCAC3FwAAgBgAADgZAgCzGgEAVRsAAJAcAADzHAEAPR0AAEgdAABUHgAA5z8A/kBE4QfAAAAcXe8hEgAA8BQAAI4XAACuFwAAtRcAAIobAABTHAAAfxwAABgdAACAHQAAhx0AAMAeAADcHgAAKR8AAG4gAAB3IAAAAAUQMAAAAgA=") \ No newline at end of file diff --git a/api/search.index/612b0560e1.js b/api/search.index/612b0560e1.js new file mode 100644 index 00000000..e465739e --- /dev/null +++ b/api/search.index/612b0560e1.js @@ -0,0 +1 @@ +rn_("RQLFAAAYfSAWsiBkbqgZqRmqGW0dFQHFAAAWsB52bBkjggLBAAAU4GFyG4zFAAAZ2AUCwAAAEiUNJHocmx1pdBUBwgAAIpUZxh5rCxIjAobEAAAYuwGOCwAACQLPAAAfgsIAABx5bXP7AmV5Ff3EDBEAAAAbbB8UKB8T5xgXTh6yGwUCxQAAMeUY5hjXGRapHm9wocQAAMYc+wJscgUCwAAAFJ8eE3IZbW4FwMMKCAAAGE0eFW0cGageBQLCAAA8/R4ZH1wgqDgdY2kBQ+NwGQAAvRoAAJ8bAABCHgAAFABrlcMAAB8wocUAAHsZVQHDAACgLB5zmRfRHtIeRR9GHxUByAAAFC4fY2wc+wJlchWBxgAAGKYgciDzBYIBrxgAACHJAAAsHbcfMcIAADwQPRAwEiHQAACpH54gVwEA/QlIAsYAABoOwQAADQ/yPQoAAHERJYHAAAARoxlEFkkeswDABMHCAAAgkTswAAABAACDAAgABgkAABoLAABgEAAAyhB7ANEXAQC6HgAAsCAAALUgAAChxAAAyhQFAsAAABMvEhKPIG51BQLCAAAbLR8UbBtucvsCbG8FAsUAAB2jIByRIHJ0I4ECwQAAHEJlcysDyQAAHvHEAAAcgWFldBUBxQAAEJwgcxEdKwLBAAAbrmNka8AChMQAAB6bAQD+QD/3BzhTuMcgQh0dZAkeMoQJpnQD/dleyQAAHQUgt6CYd2Y7O1kHwQAAHrMalHk6mWdxYcWbCVA5JNRxuRe26wY1nTaBCaUpBJpbgPLSPAt38FC2MR5NInDm8AMAAMMGAAC+GQAArhoAACodAADfHgAAgiAAAAEQAAA=") \ No newline at end of file diff --git a/api/search.index/613fa224a8.js b/api/search.index/613fa224a8.js new file mode 100644 index 00000000..b46430eb --- /dev/null +++ b/api/search.index/613fa224a8.js @@ -0,0 +1 @@ +rn_("sckAAKUeFYHEAAAR8BxLHwUCwAAAEdoeETYecnT7A2FvciHCAABoGxodIc8AAF4gsSAFAsUAALOiHrUpH3BzdwQA/RgxHcoAAB8syQAAHvfLAAAfYsQAABnCwQAAFl8B0gsAAA==") \ No newline at end of file diff --git a/api/search.index/61d7a72d01.js b/api/search.index/61d7a72d01.js new file mode 100644 index 00000000..c9cc0f4c --- /dev/null +++ b/api/search.index/61d7a72d01.js @@ -0,0 +1 @@ +rn_("FQHIAAAQnCBzER0TgwLhAAAUSGNzocgAADgdBQLAAAARkRc1sxy0HO0eZHQjAAPAAAAZnsYAABy1Ymlzg3IcAAAxzAAA/R4ZH1wgs4H9jAEAxAAAGLz7AmRz") \ No newline at end of file diff --git a/api/search.index/6232b02dfe.js b/api/search.index/6232b02dfe.js new file mode 100644 index 00000000..ad338fcb --- /dev/null +++ b/api/search.index/6232b02dfe.js @@ -0,0 +1 @@ +rn_("IcUAAAEVOBwhxgAAjxt1HAUCxgAApJMeFiMfY2UrAswAAB99bHKhxQAArBcbldMAACChxQAAHk4FAsAAABW+GRJKG29yBYvAAAASKB8UHh0RXxAFAsAAADAdDR4NRxsVtRtwd6HEAACYGCcFAP0NwQHAAAAQx8cAAB9awgAAGrDCAAAdooIiCQAAUcUAAKkXqherF40bjhs1gsUAABRsH6cXqBczHfsCaG/nPAH+QEumGckAAB1YwgAAGOfKAAAgaswAAB9CyQAAHybtywgAAOcRAACiFAAAqBQAABwVAAA/GQAAshsAAFccAADZHAAANB4AADkeAACSHgAA8B4AAIsfAABBAAAAAAAAIcMAAAEVOBwTAP0RARDAAAAfpsAAABStxwAAHVw6MAAAAQAAAAAAEgAQAAAANws4C/cU+BTlGe0Zjxt1HIccfR2FHYsdWB5fHq0eth4AHzYfYSDzAAF0AVkdAAAVAcQAABA8HnMhEgUCwAAAscQQtrsZbnDBwwAApBQhwwAAkRyRHfMA/QDUAPTtBwAAsRBmAAcGIcUAAI8bdRz7AmV5AQD+QEumH8kAAB1YSrhR//pMqfR9GDAk5VhcwgAAGOfKAAAganBuHm5GLaZu/xY4bFuxtRTiQRmEygAAHqwtLt7HM8wAAB9CyQAAHyY7MAAAAQAAPAATAPYHAgDLCAQAWxAAAOcRAQB7FCMAohQAAKgUAAAcFQAA6RcAAG8YAAA/GQAAshsAAFccAADZHAAANB4AADkeAACSHgAA8B4AAIsfAAA=") \ No newline at end of file diff --git a/api/search.index/62fde0e779.js b/api/search.index/62fde0e779.js new file mode 100644 index 00000000..1129a122 --- /dev/null +++ b/api/search.index/62fde0e779.js @@ -0,0 +1 @@ +rn_("AUM7MAAAAQAAigACAEILAAC6DYkAIcAAAPkYnBohwQAANxAHFRWCwAAAG8AeZByhxAAAKR5x0AAAqSCqIKsgrCCtILYguCDzAgJzdgGmIAAAMcAAANcDcgQCEhWEwAAAE9gYOh37woDN") \ No newline at end of file diff --git a/api/search.index/6332a815c1.js b/api/search.index/6332a815c1.js new file mode 100644 index 00000000..1e39d5dd --- /dev/null +++ b/api/search.index/6332a815c1.js @@ -0,0 +1 @@ +rn_("VQHCAAAQjx5zVhBXEHMUdBR1FAUCwQAAFIkbFIobb3QbAsYAAB1HZHD7AmV5JQHEAACwKhlz8BSHHVUCxAAAscMasCcZaXPsFO0U7hTvFIYdscMAAIUbpQHGAAAW0R93iB3zhAJtdDUBwwAAFhUdd+sUhRiGGPsCcnP7A2VvdAUCwAAAIk8flx806RTqFIQYYWwVAcMAABb7HnffGSHDAACvG3YeJQHDAAATVB5u0x7oHrMAwAjIxAAAGfaCIRUAAAWDwAAAxEIbFH8bBQLCAAAY1h4SwhpzdUHDAADnFOgUwh9WIDsDwAAAEiBhY3IxwAAAJRImEo0XIwICwwAAG4BvcjswAAABAABYAgcAZRABAFURdgDcFAAAJBXvAAoaAAC2G3YAqB12AAUCwAAAEFwQE94ZcXIFAsAAABFNFhhJHW1v7w39FsEDxQAAHiLCAAAYkKHDAABUEAUCwAAAoHMNclEQUhBTEOsZuBqtHKgfZHTF/cdSAQIAABV7HBUwHRZuHhPjGRfHHhUW5zUA/UStB8UAAB8swAAAHrDIAAAZ6uS0CAAAiRAAAIcYAADyGAAAoRkAAHAAAA==") \ No newline at end of file diff --git a/api/search.index/638c766e8c.js b/api/search.index/638c766e8c.js new file mode 100644 index 00000000..341b7980 --- /dev/null +++ b/api/search.index/638c766e8c.js @@ -0,0 +1 @@ +rn_("NYLAAAASXhAgCaUcphwVgcMAABSuGncQVf3AUgFCAAAVexwVMB0Wbh4T4xkXxx4YlRsKDRUWFhYXFhgWBQLBAAChhhYmbhx4H21zBQLCAAAavR6lmRttc2MAwAiIyQAAIKXlRRkAALAZAADEGQAAXhwAAGYcAABJHwAAOBABIwACzAAAHo5jc+G0GAAADR0AABDByAAAQhsVhMMAABPYGDodQcYAALYYjhmPGZAZQcMAABQWtxezGrQaQcEAAEEQcxB0EHUQ4wD9QKEGwwAAGPTDAAAY9cgAAB0v5tUIAABNCgAA1QsAAMgTAAAJFQAA4BkAAPMZAAABAgEQBYzAAAAVQh0SAhJVA8IAAKRtHxlvHxaTHWVpdmIZYxlkGQcfOh8VAcAAAB3PH2KUHfuGKwLgAAAQPmx09zsA/ZOFRssAAB6NAeQHAAA=") \ No newline at end of file diff --git a/api/search.index/6414adaa69.js b/api/search.index/6414adaa69.js new file mode 100644 index 00000000..4f99098b --- /dev/null +++ b/api/search.index/6414adaa69.js @@ -0,0 +1 @@ +rn_("BcHAAQIAAGYTGRQZFRkWGfUelR8yhhDjEVEeW3oeex58Hn0elyBRyQAAMxw0HDUcNhxrIFufwgAAHsfVAAAgjsEAABaE") \ No newline at end of file diff --git a/api/search.index/64a9817806.js b/api/search.index/64a9817806.js new file mode 100644 index 00000000..3c1bbbb5 --- /dev/null +++ b/api/search.index/64a9817806.js @@ -0,0 +1 @@ +rn_("BQLAAAASDR0RdxxpdBUBwwAAFG8ZZ8MZAUXhcBgAAC8eAABDBQLAAAAYgBwTbxhucwUCwAAAEPEeI3gcgh1ydtcDAP0woQTIAAAfo+aeCAAAfRYAAO4YAAChHAAAJh0AAPYeAACJIAAAMjAAAA==") \ No newline at end of file diff --git a/api/search.index/64ae5d25e6.js b/api/search.index/64ae5d25e6.js new file mode 100644 index 00000000..9c36db20 --- /dev/null +++ b/api/search.index/64ae5d25e6.js @@ -0,0 +1 @@ +rn_("BQLAAAATCxoTDBpscHHAAACLC4wLjQtbFLIcAx8jHycIAP6AhoYNwwAAFQfJAAAfjcYAAB9M2AAAILPIAAAej8sAAB7IwQAAGJTLAAAgdO9RCgAAiAsAAKcNAABtEAAACBIAAFsWAAB2GAAAThkAAKcZAACbGwAATRwAACseAACMHgAALx8AAGYgAAB+IAAAERAAMAAAAAAbhsoAABuV") \ No newline at end of file diff --git a/api/search.index/653ba0a40d.js b/api/search.index/653ba0a40d.js new file mode 100644 index 00000000..be22d689 --- /dev/null +++ b/api/search.index/653ba0a40d.js @@ -0,0 +1 @@ +rn_("BQLAAAAbfyBSehkPHX4drB9vIHJ0G4nKAAAgcysCyQAAIGdldAUCwAAANVUexB7JH6W7HnBzwcQAAKkb+4OhxQAAuRlBxgAApR+1H4YgkiAVAcQAABlXIHPgGPsCcHY1AcUAABbQH3eDHWsemR8FAsIAABhmIBp+IHJ1+4kVgsMAABQyH3sfE4GDxwAAH6oXHwD9VNQHxQAAHwPHAAAfPsEAAB65xwAAH3HuzwsAAJoNAACcFwAA3RcAACYZAACYGwAAkxwAAAEdAAALHQAALR0AAO8eAACzHwAAvB8AAMUfAABoIAAAIgQAAQAAAAA=") \ No newline at end of file diff --git a/api/search.index/6629af8f4f.js b/api/search.index/6629af8f4f.js new file mode 100644 index 00000000..55537570 --- /dev/null +++ b/api/search.index/6629af8f4f.js @@ -0,0 +1 @@ +rn_("BfzADwAAABDTGBDUGBDVGBDWGAX8wP8DAAAQyRgQyhgQyxgQzBgQzRgQzhgQzxgQ0BgQ0RgQ0hgF/MD+AwAAEMAYEMEYEMIYEMMYEMQYEMUYEMYYEMcYEMgY+/wHAA==") \ No newline at end of file diff --git a/api/search.index/66c90a1937.js b/api/search.index/66c90a1937.js new file mode 100644 index 00000000..ec434ed2 --- /dev/null +++ b/api/search.index/66c90a1937.js @@ -0,0 +1 @@ +rn_("YcQAAGYbZxthHWIdYx0gH1HHAAByHnMedB51HpUgAUXiZx0AALkfAAC7HwAAUAArAsMAAB5Ec3Q7A8MAAB2nZXR5YdQAAKkgqiCrIKwgrSC4IPsDb3N0") \ No newline at end of file diff --git a/api/search.index/66d1d89529.js b/api/search.index/66d1d89529.js new file mode 100644 index 00000000..b611ac14 --- /dev/null +++ b/api/search.index/66d1d89529.js @@ -0,0 +1 @@ +rn_("ocIAAJYYAUPk4xgAANQZAABuHQAA6R4AAL0fAAASEgAVAcIAAB3UH3LvHAWKwAAAFqkaE+oXwccAAKUaIcMAACMV+R4FAsEAABOxGxamHWZrI4GJyAAAHaUVAcYAABUPH3OkGkMAA8AAABfcwwAAGpthY2/2PxAAAAEAZgmnBOAAGgAFAsEAABsOHxltHmJs9wUA/SmBDsQAAB70wgAAHkbzSQoAAAsR4QP7/RABQQ==") \ No newline at end of file diff --git a/api/search.index/688ec56dbc.js b/api/search.index/688ec56dbc.js new file mode 100644 index 00000000..19edbb33 --- /dev/null +++ b/api/search.index/688ec56dbc.js @@ -0,0 +1 @@ +rn_("ocUAAE8cBQLAAAARLxkWSB1ycwUCwAAAExYdExcdbHAbAsQAAByyZm4jAAPDAAAarcgAAB/OYmVu4SUSAACNFwAAEIcDAf4ACpAMwgAAGazJAAAfGsMAABuAOzAAAAEAAFgCBwBlEAEAVRF2ANwUAAAkFe8AChoAALYbdgCoHXYAoccAALseocUAALkZQcYAAKUftR+GIJIgFQHEAAAZVyBz4Bj7AnB24wD9AMQBwQAAHrnqzwsAAJwXAAAmGQAAmBsAAJMcAAABHQAA7x4AALMfAAC8HwAAxR8AAGggAAAgAAEAAABLA8EAABeAwQAAIIplaXkFAsEAABDOHxGPGGRpzwHQBMTMAAAfosIAAB9j") \ No newline at end of file diff --git a/api/search.index/68990c4e87.js b/api/search.index/68990c4e87.js new file mode 100644 index 00000000..16ad6828 --- /dev/null +++ b/api/search.index/68990c4e87.js @@ -0,0 +1 @@ +rn_("IcoAAGMciCAJAsEAABCH0gAAIIJrdAEDwsAAyAAAH9PBAAAeXMUAAB5n77ANAACwGAAA6BgAAIMZAADoGQAAehsAAKIcAABnHQAAhx4AAOweAACEHwAAmB8AALkfAAC+HwAAdiAAAHggAAAwCAEVEAAgAPsDaW9y") \ No newline at end of file diff --git a/api/search.index/68c6ebe70f.js b/api/search.index/68c6ebe70f.js new file mode 100644 index 00000000..f23d9423 --- /dev/null +++ b/api/search.index/68c6ebe70f.js @@ -0,0 +1 @@ +rn_("wcMAAFcbocUAAHcZMcIAAJkamhojHgEDAckAACClZuGwGQAASR8AAIGxxwAAVB37A2hpeWUCwAAAFbQbGFcgZWYXEpMXgRjiGOYZrR8VAcMAABawHnZsGSOBAsoAAB/UcHMFwMAIJAAAFI4gFoUfJLMbWxwBTDswAAABAAB6AAEA1R96AFHBAAAiFiMWWRZaFpEeIcIAAJUZxh4FAsAAABOtG6NtH3J2BQLAAACwuxkR4BRscgX8wA8AAAAQ0xgQ1BgQ1RgQ1hgF/MD/AwAAEMkYEMoYEMsYEMwYEM0YEM4YEM8YENAYENEYENIYBfzA/gMAABDAGBDBGBDCGBDDGBDEGBDFGBDGGBDHGBDIGPv8BwABAA9mKa+PT0YCxImSD7WqvVTGAAAdMcgAAB9vSRDvkv51ojrA7l0Dnpovf2Y+LPYzVA195GwQEYHsAVRKuY8k9f1WSsUAAB2TFI+00Z8wYWVmaWttbm9yc3R1dnk7MAAAAQAAYwAuABYJAAA8CQAA/wwAAEsNDwCADRcAYRAAALsQAQBIEQAAUBEEAAkSAAALEgAAKRIAANUUAAAGFQAAghcBAOAYAAAbGQIAYRkDAIIZAADSGQAA/hkBAEYbAABbGwAAfRsAAGscAACIHAIAoBwAAKQcAADPHAAA7xwAAAcdAAAuHQAAfx0AAEAeAABOHgAAgh4BALQeAQDWHgAA4B4AAAcfAAAfHwAAOh8AAIgfAADUHwAAmSAAAKMgAAA=") \ No newline at end of file diff --git a/api/search.index/68d3bd3eb5.js b/api/search.index/68d3bd3eb5.js new file mode 100644 index 00000000..5186e054 --- /dev/null +++ b/api/search.index/68d3bd3eb5.js @@ -0,0 +1 @@ +rn_("BQLAAAAVGBkW4B5sbwcEAP0VsQPEAAAausUAAB0yxQAAHQnHAAAcQckAAB/CyAAAGefKAAAcf8MAAB1aAdgHAAA=") \ No newline at end of file diff --git a/api/search.index/6a1d0f09ec.js b/api/search.index/6a1d0f09ec.js new file mode 100644 index 00000000..4615b45d --- /dev/null +++ b/api/search.index/6a1d0f09ec.js @@ -0,0 +1 @@ +rn_("BQLGAAAUOR4STBxxcgUCxwAAFpsgF6IgY3AFAsIAABN0Gx5gIGFrBZnAAAAWQx4UWxkezB8nBgL9PYgDyAAAHXrhAAAZ+cMAAByUwAAAHovOAAAfoM4AAB/N4TAZAAB3GQAAAfuJ") \ No newline at end of file diff --git a/api/search.index/6a5eb13b43.js b/api/search.index/6a5eb13b43.js new file mode 100644 index 00000000..e181dd92 --- /dev/null +++ b/api/search.index/6a5eb13b43.js @@ -0,0 +1 @@ +rn_("BQLAAAAbfyASDx1ydBuJygAAIHMrAskAACBnZXQxxwAAVR7EHskfNQHFAAAW0B93gx1rHpkfBQLCAAAYZiAafiBydfuJFYLDAAAUMh97HxOBg8cAAB+q1wEAwBJixwAAHz7h3RcAAAsdAABA") \ No newline at end of file diff --git a/api/search.index/6abe6cdc9f.js b/api/search.index/6abe6cdc9f.js new file mode 100644 index 00000000..f6519b12 --- /dev/null +++ b/api/search.index/6abe6cdc9f.js @@ -0,0 +1 @@ +rn_("IcAAAIQWFh0hxAAAUBkSHyMBAsIAABijbW/j2gsAAGMUAADbGQAA3R4AAGEQJQHAAAAUdh90MBylHaHCAAA5EGMB/QQBBsYAAB81xwAAHxHybRkAADcC+wJlbPMAAmZzATELAAA=") \ No newline at end of file diff --git a/api/search.index/6ae532761e.js b/api/search.index/6ae532761e.js new file mode 100644 index 00000000..ae9e066e --- /dev/null +++ b/api/search.index/6ae532761e.js @@ -0,0 +1 @@ +rn_("McMAAPMUixiMGBWBxgAAEv4eih0jBALEAAAdGWh38p8XAABbBaHEAACdF9MC/TAABsUAAB8l4yoLAADBCwAAnxwAALIgAABBACuJxAAAHkw=") \ No newline at end of file diff --git a/api/search.index/6b02d7f067.js b/api/search.index/6b02d7f067.js new file mode 100644 index 00000000..91e4a7de --- /dev/null +++ b/api/search.index/6b02d7f067.js @@ -0,0 +1 @@ +rn_("BQLAAAAUXh4exR9wdCHEAACLHVgeJYTDAAAUnh95HZ0gxwEAwgRTxAAAGKfNAAAfmMUAAB779GQQAAB7CdgAsAQ=") \ No newline at end of file diff --git a/api/search.index/6b20a05c44.js b/api/search.index/6b20a05c44.js new file mode 100644 index 00000000..fbeebf11 --- /dev/null +++ b/api/search.index/6b20a05c44.js @@ -0,0 +1 @@ +rn_("BYbAAABxURBSEFMQ6xm4Gq0cqB8cnxwVAcAAABeyG3mdCAWFwAAAEd4UEd8UpQLCAAAUXBtG7BztHO4coB9jc0YRVQHBAAAQSR1zbxBwEHEQchB1GHcCAP0QkQPOAAAfQMQAABTd5XsGAABPCgAASREAAMoUAADzHgAAKB8AAAERAAUCwAAAG98eG5wdZXOhxQAAARolgcIAABEtHAAVJR+lg8AAABLaGRZRHs0UBQLCAAAVqh4UUB5jcwEBAcMAABfsY+YDCQAAVgoAAD8WAABJHAAAbRwAAIkeAAB6HwAAIAEAAAWKwAAAGPkeGwIfpQLDAAAUkh0Wvh5zd4AZGwLHAAAe+GFsBQLAAAATCxoTDBpscBMDAssAACB0c3bzbRAAAB8OowDzAP1dABrjSwoAAOwRAADHGQAAPh0AABEAMcAAACMNcw10DfvABIc=") \ No newline at end of file diff --git a/api/search.index/6b513fafb4.js b/api/search.index/6b513fafb4.js new file mode 100644 index 00000000..c9d6b2f9 --- /dev/null +++ b/api/search.index/6b513fafb4.js @@ -0,0 +1 @@ +rn_("pQLFAAAWiBsXix5jdXkUMcUAAFgQdxR4FCHFAAAYEnYUBQLBAAAYBx+j0xRpczHGAAAdEh4SSxb3AQD9koAJxAAAGTCEJAMAAA==") \ No newline at end of file diff --git a/api/search.index/6b572ed60d.js b/api/search.index/6b572ed60d.js new file mode 100644 index 00000000..bf9e4530 --- /dev/null +++ b/api/search.index/6b572ed60d.js @@ -0,0 +1 @@ +rn_("FQLCAAAToxmlLB5lZ78ZBQLFAABkbh1vHeke6h7rHr0fUj0cPhw/HEAcbSB0dRvAAwLDAAAZwsMAAB4hQcIAAJ0ZrxywHMwfMcgAAIIegx6ZIAUCyAAAEoogIO8esx9pedMCBMUAAB+8aXFzdeGOFwAAKR8AAAKxyAAApR77gxWBxAAAEfAcSx8FA8AAABA2HhBQHjFUFNgcPh9lc3UrAsAAAB7acnT7A2FvcgUCxwAAFRQfFNAeY3Mbg8kAAB8sNQHIAAAWfB9jaRseHyEfBdDAAAYAAFm/HMAcwRzCHHsgIWgbGh0RahwhzgAAXiCxIKHFAACHHnUBxAAAEGoYcycWKBZ3FqMaohujG04dBf3CBAALAAAdkCAaQR8QRx4yzhnpHGcgBf3CFUAAAAAesCAdtyAXjSAU/Bz7Aml2zwP9FAAnygAAIG/EAAAZogUCxQAAs6IetSkfcHMBAP2YuV9eD5H52EZmxdPUzgAAH1wfPRn2rFZKkA2/Bsxly5HJAAAe98kAAB8LywAAH2LEAAAgd2Ig898/ZYOzYqDBAAAWX0VibnqIOjAAAAEAAAAAAB8AEAAAAOgIUwpUClUKRwtIC0kLSgtLC0wLTQvSCzwNuxekGLoY/RkLGoEckh06HjseUB5XHooe0B7bHg0fpB9QIFEgYiA=") \ No newline at end of file diff --git a/api/search.index/6b724c0c85.js b/api/search.index/6b724c0c85.js new file mode 100644 index 00000000..2f16cd8c --- /dev/null +++ b/api/search.index/6b724c0c85.js @@ -0,0 +1 @@ +rn_("QcEAAB8Sbxn1GZYbBQPCAAAUrRoWshwZzh9iZW6hxwAAux6hxQAAuRlBxgAApR+1H4YgkiAVAcQAABlXIHPgGPsCcHbjAP0AxAHBAAAeuerPCwAAnBcAACYZAACYGwAAkxwAAAEdAADvHgAAsx8AALwfAADFHwAAaCAAACAAAQAAACsCwQAAIIppefOBAnR6QcEAAHANcQ1yDWkee8AQYcIAABqu") \ No newline at end of file diff --git a/api/search.index/6c466bfc52.js b/api/search.index/6c466bfc52.js new file mode 100644 index 00000000..0e5ccf9a --- /dev/null +++ b/api/search.index/6c466bfc52.js @@ -0,0 +1 @@ +rn_("JQHGAAAXtR5mnxmAHKUCwwAAFmEeFmIebXfIGRsCygAAII5scwUDwAAAKNEciR+hmhcXWx5iZWwjgQLAAAAfxmN5zwH9EEkBzQAAH0DEAAAU0Q==") \ No newline at end of file diff --git a/api/search.index/6c5c9a6830.js b/api/search.index/6c5c9a6830.js new file mode 100644 index 00000000..a94eeeec --- /dev/null +++ b/api/search.index/6c5c9a6830.js @@ -0,0 +1 @@ +rn_("NYLBAAAXlB3xFPIUihglA8IAABOmIBacHhR6HW9zdKEfwR/7AmR0UcMAACISIxJcFl0WXhahyAAA0R7FAcMAABf6HnKdGiODAscAAB7FZWwFAsAAABHHExW3HmFy0wDABErHAAAcrfNICgAACxGeATMAA8QAACBiZW104/YKAADnFwAALRwAADUeAAAxAAWRwAAA4mUWpEsZwcEAAEwRMwEDwgAAFmtpbnL8LwkAAL4BAQBWBQEAXAAqAwEGKgC0AJ4AJQAbAsEAACCCb3lRywAAnh2fHaAdoR2LIAUCwAAAEwMVGGAdY2UVAcQAADA6Hjseih5zuBcrg8EAABZjBwwA/gBF8AfHAAAc9ccAAByQwwAAHqHFAAAY4sQAABlKwwAAGJ7CAAAWZMkAAByU4y0JAAD/CgAArRkAAPIcAAATACHDAAAbFfge+wJhaBcQAP4AQ+AVwgAADbjCAAATO9UAACCSwwAAFRzKAAAdncwAAB7DwgAAG2ziBggAAEoQAABXGwAAIDAFlMAAAByCHBotHxUCwgAAGA4fFVgdZHRwHRMAAsgAAB5tYmwB6wMAACUBwAAAGSQdZO8HJRZVAcEAACB5GcAZcwkJXwoZFbEaqBwXCAD+ADRiQcMAABn1xAAAGVTGAAAZv8UAABiawwAAFKjCAAAQyAFxHQAAAUH4dhgAAAEA1wBZAKYC3gE7AhgAI4ECwwAAHS1pbBOBg8AAAB0Gvz79DDwP4AAAHRc1AeFckw==") \ No newline at end of file diff --git a/api/search.index/6cb285702c.js b/api/search.index/6cb285702c.js new file mode 100644 index 00000000..b8573a6d --- /dev/null +++ b/api/search.index/6cb285702c.js @@ -0,0 +1 @@ +rn_("BQLAAAAxbBZEGY4gFmgfZXShyQAAgxwFAsAAABmMHhL2FHN0wwL9RAEDywAAHv/GAAAc0MQAABmt4S8LAADDCwAAAyHEAABQGRIfIwACwgAAGKNtb+PaCwAAYxQAANsZAADdHgAAYRD7hgUDwwAAFgMfJX4drB8YcR9lc3TByQAAQhtbwAIFwgAAGg0lAcAAABR2H3QwHKUdocIAADkQYwD9BAEGxgAAHzXHAAAfEfJtGQAANwIrAtMAACBoY3L3AAD9EAkGxQAAGW7iXQEAACIdAAAiHgAAEAA=") \ No newline at end of file diff --git a/api/search.index/6ced23c229.js b/api/search.index/6ced23c229.js new file mode 100644 index 00000000..0490fa2c --- /dev/null +++ b/api/search.index/6ced23c229.js @@ -0,0 +1 @@ +rn_("BQLAAAASvBcXBB9hciX+wABAcAIAACIKFageMeUY5hjXGRapHhQ/GXS/F8AXwRfCF8MXxBecH+gLwhkFAsAAABGRIBL1GGN04wD9EgAGxAAAH2HhJxkAAGogAAAg") \ No newline at end of file diff --git a/api/search.index/6ced3a539e.js b/api/search.index/6ced3a539e.js new file mode 100644 index 00000000..0ce43c0b --- /dev/null +++ b/api/search.index/6ced3a539e.js @@ -0,0 +1 @@ +rn_("FYHGAAAYpiByIPMFggGvGAAAIckAACwdtx8xwgAAPBA9EDASIdAAAKkfniDTAP0JCALGAAAaDvI9CgAAcRFTAcAAwcIAACCR4RoLAADRFwAAAQ==") \ No newline at end of file diff --git a/api/search.index/6cf160c631.js b/api/search.index/6cf160c631.js new file mode 100644 index 00000000..72ae5df4 --- /dev/null +++ b/api/search.index/6cf160c631.js @@ -0,0 +1 @@ +rn_("ocMAALgYIcMAALcYAh8xyAAAfx+AH64gNQHEAAASVh5jdx54HpYg8wACaWzl5xQAAHQZAACmGwAAJB0AAMIfAABWIAAAECAA+8EEIA==") \ No newline at end of file diff --git a/api/search.index/6d4ff8d47c.js b/api/search.index/6d4ff8d47c.js new file mode 100644 index 00000000..36e6c48b --- /dev/null +++ b/api/search.index/6d4ff8d47c.js @@ -0,0 +1 @@ +rn_("ocUAAAEapYPAAAAS2hkWUR7NFAUCwgAAFaoeFFAeY3MFisAAABj5HhsCH6UCwwAAFJIdFr4ec3eAGRsCxwAAHvhhbPMAwhZA4ewRAADHGQAAEA==") \ No newline at end of file diff --git a/api/search.index/6de32dc242.js b/api/search.index/6de32dc242.js new file mode 100644 index 00000000..e1000b17 --- /dev/null +++ b/api/search.index/6de32dc242.js @@ -0,0 +1 @@ +rn_("UcIAAD0cPhw/HEAcbSABROTjGAAA1BkAAG4dAADpHgAAvR8AABISABWExAAAJPYeiSAYHRMAA9UAACCxxgAAHXN0dXeC6REAABUBxgAAoAMdcwAaBQLCAAAYYR8TpB9jbbcDBP4ACGAHxwAAHZHyJA0AAGwTi8AEwcEAABfVywAAG5XEAAAQZw==") \ No newline at end of file diff --git a/api/search.index/6e798a19a8.js b/api/search.index/6e798a19a8.js new file mode 100644 index 00000000..c270f029 --- /dev/null +++ b/api/search.index/6e798a19a8.js @@ -0,0 +1 @@ +rn_("scMAAIUbpQHGAAAW0R93iB3zgwJtdAUCwAAAFNMXpOAZY3L7AmVzNQHCAAAWFR136xSFGIYYKwLJAAAdmWV2AUk7MAAAAQAA7QACALYbdgCoHXYAcwD9CAAbwQAADUDFAAAZ4uXrBwAAyQgAAPoMAADBHgAAyB8AAF8gAAARIAA=") \ No newline at end of file diff --git a/api/search.index/6e9e1b84b2.js b/api/search.index/6e9e1b84b2.js new file mode 100644 index 00000000..09cf834a --- /dev/null +++ b/api/search.index/6e9e1b84b2.js @@ -0,0 +1 @@ +rn_("BQLAAAATrxoYrh9jZQUCwAAAF4EfKq8eNB9haBcCAP0cQQLDAAAe88YAAB9zwQAAF5LCAAAZmeN5EAAACRkAAEAfAACTHwAAEwAjAALBAAAeamlz6AYNAAA0FgAAIRkAAEAZAADsGQAAcBsAAGAfAACWHwAAWSAAABQwAwAA") \ No newline at end of file diff --git a/api/search.index/6f74cdb6fd.js b/api/search.index/6f74cdb6fd.js new file mode 100644 index 00000000..d02c0c3f --- /dev/null +++ b/api/search.index/6f74cdb6fd.js @@ -0,0 +1 @@ +rn_("AUI7MAAAAQAA7QEFAPkMAACIFvUA1xgAAMYaegDVH3oAscQAAIsc8wGCOjAAAAEAAAAAABMAEAAAAAYLBwsIC1QUARWlF3oZOBx6HLoc2BwPHX4djx2bHT4fdh+sH28gkyABQPvoCAAA0w7/AEMBlQO+AAcAeQA9AEMBAQAFAsAAABdCHRFOHmNvJYHGAAAS/h4dHYodocYAALkXUwDCIgDEAAAdGevzBwAA3AgAAF0QAACjFAAA+xQAAJ8XAAC8FwAAvhgAAPsZAAD6HAAAWh0AADYeAAALACAAEQD7/RABSg==") \ No newline at end of file diff --git a/api/search.index/6f8d14a81b.js b/api/search.index/6f8d14a81b.js new file mode 100644 index 00000000..56226d1c --- /dev/null +++ b/api/search.index/6f8d14a81b.js @@ -0,0 +1 @@ +rn_("BQLBAAAQrhQUYxtueRWExAAAE18dSRlbwIoAwgAAGJ0rAsYAABnHaG8BggLQAAAgtcEAAB5LaXYJAsMAAB5K0QAAIHxlbAUCxQAAGVsfEKAeaXkVAcMAABWdH3D/HMccAP1EuQ7NAAAe6MsAAB6dwgAAFL7DAAAWeMIAAB0AAdoZAAA=") \ No newline at end of file diff --git a/api/search.index/707a274c3a.js b/api/search.index/707a274c3a.js new file mode 100644 index 00000000..c9ee9012 --- /dev/null +++ b/api/search.index/707a274c3a.js @@ -0,0 +1 @@ +rn_("IcQAAHocmx0FAsEAABHjGRAlDWZrhwIA/VIBRsQAABx7xAAAHTDFAAAebsYAAB7HxwAAG5XpCg0AAL4UAAAVFgAAYRsAADEdAAB8HQAAaB4AAGgfAABxIAAAkSAAAAAwAAAA") \ No newline at end of file diff --git a/api/search.index/70bc5e4961.js b/api/search.index/70bc5e4961.js new file mode 100644 index 00000000..dd464033 --- /dev/null +++ b/api/search.index/70bc5e4961.js @@ -0,0 +1 @@ +rn_("BQLAAADWVhkWTx5vdDUBxAAAElwea+gZhB+YHysCxgAAHohzdCOBAsIAABzyYXQFAsAAABgDGhp3IGV0BYnBAAASoCAUqCAjiwLGAAAgr3Bz84ECcHMVAcIAACKVGcYeawsSIwCGxAAAGLvSYQsAAC0VAcMAABA8HnMhEvsCYXQFAsAAABtuILW7GWV0scEAAMQQ+wJucKUBwwAApmofd44cGwLjAAAUpGF5E4ECyAAAH5ZuciHDAACRHJEdAUI7MAAAAQAA9gACAEQLAAA8D/UAAUbh3BwAADkfAACwa8AEBuIAABRqBQLEAAAapCAYmiBkcwUCwwAAHbcgF40gY2UbAsQAAB6HcnQb/RABDMkAACB7zAAAIJnIAAAgbQUCwwAAFGwgFXogaW1DgcAGQM8AACC0zAAAIJ8FAsAAABTnHhp5IGVsBQLAAAAWlR8blyBidiuFyAAAIGsVAcAAABeMIGKZHvMCAXMBPx4AACsCxQAAH5RtdAUCwQAAFJYgGK4gaWwjhwLHAAAgp2RlAYQC0wAAILjHAAAglW9zAYL+QF+qCRNRdIvSxgAAH1g2vxiAItEAACCcxwAAH5PEAAAe5mCpHH2+TSPYnN3MAAAgmCH7MtfvWCPmxZhgBES0yFrAR9d9AUM7MAAAAQAA9gACAEMLAABGDvUABQLCAAARmR0S8hltcxsCwwAAHVtscxuKxQAAGKoF/cCASAEAAB04Hx+LILNgFhTxHKHDAAB4GAEA/e33D3TZ1gY7BBl7tUEE+VpI90+PzqtWJYWO/OHHAAAdEwvKysScygAAH8Qy2QJUW1tfFtlywgAAGIA9AgLUo0VJ0rmtesBZ/Oced0irTlbAS77GzAAAIFk6MAAAAQAAAAAALAAQAAAAZgHtBycLNQuTC7QLtQvNC84LoQ1dFKkUqhSrFKwUCxWeGKIYsRiyGN4YBBnNGeoZghuDG4sbjBuDHIQcIx1cHZ4dnx2gHaEdHx4hHqAeCx8THxofXiCLILEg") \ No newline at end of file diff --git a/api/search.index/70ceae1c15.js b/api/search.index/70ceae1c15.js new file mode 100644 index 00000000..54086c28 --- /dev/null +++ b/api/search.index/70ceae1c15.js @@ -0,0 +1 @@ +rn_("BQLDAAAUpSATPR9jcgUCwAAApLQeEYgQY237iQUCwAAAopYQFVcgY3OxwwAAkxD7AmxyJQHCAAATVB5u0x7oHiWExQAAFJ4feR2dICWExAAAFGMfCB2UIPOBAmxyUcMAALsavBpjHIMgiCDzgQJydNHFAAA0FqHDAABJHAUCwQAAFjEfFy4dY2T7A2Rpdvv9EUEB") \ No newline at end of file diff --git a/api/search.index/70dfc0710c.js b/api/search.index/70dfc0710c.js new file mode 100644 index 00000000..fbda9c64 --- /dev/null +++ b/api/search.index/70dfc0710c.js @@ -0,0 +1 @@ +rn_("FQHCAABkRRlGGUcZSBnEGcUZdDkLocMAALQYQcYAAKocqxysHGQgBQLAAAAUrhsVMB9hbPMDwALA1JsfAAAHB/XzAAJkaTowAAABAAAAAAAfABAAAABKFGYUZxSfFKAUoRQMFToWYBZhFmIWgRfFF8YXxxfsF5EYmBiZGJsZmxpcG3Yddx14HZMelB6aHgofLh+CH48g") \ No newline at end of file diff --git a/api/search.index/7108fcbd23.js b/api/search.index/7108fcbd23.js new file mode 100644 index 00000000..6023b8e1 --- /dev/null +++ b/api/search.index/7108fcbd23.js @@ -0,0 +1 @@ +rn_("wcoAAKkbFQHHAAAQrR9zfx0xwAAAQg1DDXQdQwEDwQAAEgfCAAAU22FpczswAAABAAB+AAYA7Rd3AJMYAABlGQEAPR4AAJ4eAQBbIAAAGwLAAAAeIW5y+5M=") \ No newline at end of file diff --git a/api/search.index/7171e6c19b.js b/api/search.index/7171e6c19b.js new file mode 100644 index 00000000..e32b3d23 --- /dev/null +++ b/api/search.index/7171e6c19b.js @@ -0,0 +1 @@ +rn_("Bf3AEAAHAAAWAx/FqRslfh2sHxhxHzUCwQAAUhoZaxtlHHMgfyAW0B9pd4Mdax6ZH6HJAADzHAFOOzAAAAEAAHoAAQDVH3oAq/0BQILDAAAaCsEAABFLNYTAAAChRBx7DdEUlBcbAsIAABt8bXKXAwD9cEFAxAAAH3rAAAALlTswAAABAACNAAUAxQYAAKUYAACzGAAADxqJABsfAAAFAsAAABXTHxQnH2FsIcIAAJwZVhsBQTswAAABAACKAAIAsxgAAA8aiQB1wMAKQAAAFFwbGYIcRuwc7RzuHKAfMAvKC8sLRhFHEWEUYhSHAwD9aSEExAAAHwXFAAAZw8AAABUExgAAH28BYR8AAAEAAeAAAB5wc+XBEAAATBEAALQUAAA7FgAASxsAAKQdAAADEWAVhMMAABPYGDodGwLLAAAejWhw/wTABtzNAAAerMIAAA0I") \ No newline at end of file diff --git a/api/search.index/71ed4f3a5e.js b/api/search.index/71ed4f3a5e.js new file mode 100644 index 00000000..a13f0400 --- /dev/null +++ b/api/search.index/71ed4f3a5e.js @@ -0,0 +1 @@ +rn_("BYnBAAASoCAUqCAjjQLGAAAgr3BzpQHEAACmah93jhwTgQLIAAAflm5yBQLEAAAapCAYmiBkcwUCxAAAHbcgF40gY2Ub/RABDMkAACB7zAAAIJnIAAAgbQUCwwAAFGwgFXogaW1DgcAGQM8AACC0zAAAIJ8FAsAAABTnHhp5IGVsBQLAAAAWlR8blyBidiuFyAAAIGsVAcAAABeMIGKZHvMCAXMBPx4AACsCxQAAH5RtdAUCwQAAFJYgGK4gaWwjhwLHAAAgp2RlAYQC0wAAILjHAAAglW9zV/aC/kBbqgnGAAAfWMcAAB+TxAAAHubMAAAgmAUCwAAAH4sgFPEcbHJXBQD9TFILxQAAHVvKAAAfxMwAACBuxAAAHPLMAAAgWeuTCwAAqRQAAAsVAADNGQAA6hkAAIIbAACLGwAAgxwAACMdAACeHQAAEx8AAIsgAAADAAERAwA=") \ No newline at end of file diff --git a/api/search.index/71ef95c07a.js b/api/search.index/71ef95c07a.js new file mode 100644 index 00000000..e376d346 --- /dev/null +++ b/api/search.index/71ef95c07a.js @@ -0,0 +1 @@ +rn_("BQLCAAAU4RQbEh1lcgUCxgAAGEgfFnIfYnMbwAwgxAAAHGHMAAAeIQWDwAAAFI0ZHasfGwLMAAAfeHB0FQHDAADAHRVzkx01AcEAABYBH3f3GfgZXxyhygAAbR/zggJjZfOBA2lzdK8B/TFBAMMAAB1ZwgAADQA=") \ No newline at end of file diff --git a/api/search.index/71f64a1dda.js b/api/search.index/71f64a1dda.js new file mode 100644 index 00000000..99ae3930 --- /dev/null +++ b/api/search.index/71f64a1dda.js @@ -0,0 +1 @@ +rn_("BcDDgGAAABKzHhV5HCV6HJsdBQLAAAASDR0RdxxpdAUCwAAAEPEeI3gcgh1ydlODA8gAAB+jZWZ0GwLGAAAdDm50McUAAP0eGR9cIPMAA2Jpc+NVCwAABR0AADceAABcHwAAEBAFAsIAABjWHhLCGnN1QcMAAOcU6BTCH1Yge/1FAAHAAAASIA==") \ No newline at end of file diff --git a/api/search.index/72086a880f.js b/api/search.index/72086a880f.js new file mode 100644 index 00000000..1389faa5 --- /dev/null +++ b/api/search.index/72086a880f.js @@ -0,0 +1 @@ +rn_("IcgAACwdtx/zAAFs6N8KAABmGAAAPx0AAHoeAAAzHwAAWB8AAHcfAABwIAAAlyAAABMjAAAAFQHBAAAYpxxzLhwVAcUAABdgIG0iH/MAAXMBDw0AAAXAwQRCAAARdBsYlx8WaiD7AmFrAUXirhcAAH8cAACAHQAAUAAjgQPEAAAY38YAAB9RcnN1+wNhY24VAcIAABJ+GGxvDRUBwwAAE4QbZJUXBQLFAAAUhh0Uhx1vdBWEwgAAoUQclBc1AcAAABBwHnM7FjwWpB37AmR0AUTjcBkAAL0aAACfGwAAQh4AABQAGwLDAAAZcmxvdwEC/QSRCMoAAB5b5W0NAAAGGQAAOx0AAEwfAAC4HwAAyh8AABEQAfsCZnQxwQAAPBA9EDASUwADwgAADPhldXkBNgsAACHDAABeILEgocIAAFEZFQHAAAAXmB9zoB4jgwLIAAAfW2l5PwHBAYbWAAAgs8EAABTaBQLCAAAQFhIS5RRldEHCAAAIFUwWrBitGAWKwAAAFqkaE+oXwccAAKUaIcMAACMV+R4FAsEAABOxGxamHWZrI4GJyAAAHaUVAccAABUPH3OkGgUCwQAAGw4fGW0eYmz3BQH9KYEOxAAAHvTCAAAeRvJJCgAACxGxzAAA6R4BAojGAAAfheVQEQAA/hkAAGscAACCHgAA1h4AAJkgAABBAQCxyQAAYR3zgQNhaXPbwQKCwgAAHQkFAsAAABYOGhBXCm9zBQLAAAARmR0S8hltcyHPAACpH54g+wJ1efcrAP05aQPCAAAUacIAABkx6awIAAA9CgAAHAsAAOYUAAAIFQAATBYAAKcYAACsGAAArhsAAMceAABAAAABAA==") \ No newline at end of file diff --git a/api/search.index/724f6f232e.js b/api/search.index/724f6f232e.js new file mode 100644 index 00000000..a290cc36 --- /dev/null +++ b/api/search.index/724f6f232e.js @@ -0,0 +1 @@ +rn_("FQLEAACmhRwWhxxjck8dMcMAAPYY9xinH/sCaXIhwwAAXRCjFHHFAAB6G3sbohyjHOwediB4IDWExAAA8UsbIR1wH3Ifa8AAwc0AAB+FWwPAAAAZ8mVtdCXAxAogAACmjRsTShsWjxuiFNkcFQHGAAAQbx5zfBwhyAAASRskHyHKAAAJH4ofBQLAAAAZCB8QmR1hczUBwgAAFY0ec8cLyAvTHwXAwQBGAAAY3h4TVRsV+h6xwwAAOBnFAsAAABe4HhZ8IHJ0NB2hwQAAWRzzAgJpc+KmFwAAQx0AAFseAAADACsC5AAAFlRkdBUBxQAAFrceZFMbJxoC/V0xAcYAAB1RxAAAG1TDAAAY3sYAAB1SxgAAHVPmEg0AAH0NAAAEGgAAnRoAAKUaAABdGwAAlR4AAGEzMzD7jCUBwgAAFu0ZcsIQWBxBwwAAdRl2GVEcUhwTAYzGAAAehuUyCwAAXhQAAOQUAAAYGQAAQRwAAMkcAAAiAAD7Am50IcYAACMdEx8VAsUAABNQHRbdHm1zNxklgsEAALSLHKUXuhy1AcAAABYdHXf7FPOBkhuJwAAAFlMFjMAAABu+HxTbHAUDwAAAGCEfG7wfG70fbHR1BQLCAAAXux8UIB9zdBOBAswAACB4ZHTbBMkAAB9SYWNzdhUBwQAAFNocdlIW+wJldFv9EAQUwwAAGa/NAAAfvwXAwwBKAAAaViAXHx8UNh5DgcACiskAAB7cywAAH4vPAAAgdwUCwQAAG1EfFFIbbHYV/sJAQEgCAAAZUB8TWBwaih8U2RwVTx1RFmsDwgAAGJVhbHIFAsAAABPYHB+KIGFySwPEAAAdTscAAB8eZWx0BQLBAAAYTR8ZTh9jcAUCwQAAGrkfHXYgZHQ3AYL+QAVACccAAB7bwwAAHFfLAAAfuqHKAAAbHaHDAAD5FGvFACTRAAAghNAAACB1BQLCAAAbTB8aHR9hbQUCwQAAHbgfJpQb1xxmdAGEAs8AACCeyAAAH7dhczsDyAAAHjVhb3kFAsMAADLcGN0YxR4biB9lZwUCwwAAExwfFYcfYnMjgwLEAAAeNG9yAQD9X/gHNSym9yAQE1UTlG8OJW8GLuuefuFnf49+TTRz1BX2zAAAH0/JAAAeksIAABZQzQAAH4lXljaWXj/fwlxIKvJIaCZq5/G+PeroCgAAWBAAAHgQAAB3FAAA6RQAAJcXAACEGAAAHhkAAMYcAAAnHwAAmh8AADABEQAQAKHDAABGHFHEAACvH7AfsR+yH7Qg8wACYWTnnRAAABkSAABAFgAAmhcAAFUZAABIHgAAkB4AAKweAAAzMQAAW8AEKMkAAB8KsccAAHcbNQHDAAAWZCBzmx+pH54g8wIBZOKfFAAAmh4AAIIfAAAgAEOBA8MAABiTwwAAFONtbnUBBAHFAAAfAHLj8BkAAFMfAAB9HwAAqCAAABQAAQIBxQAAHl9y4toYAADhHgAAoCAAABQA+4krA8UAABuT0AAAIIludHVBzgAApR+1H4YgkiABBgHFAAAfNnLkkBsAAI4fAACHIAAAoiAAAK8gAAAEAAAFAsYAABGRHqRJH210+wJvcrHIAACIHDHDAACiFzIZbB4FAsEAABSLHRVeHnFz8wMBcuKhFwAAthwAAHkgAAADAEHEAADZGNEcSB+JHzUCxQAAETMeE9geaXTuGe8ZmyArickAAB5d9y4C/gBF9QFDKanvg0oE7Spc9DkWAAA1BcEAGAEbg88AAB+DFQHIAAAWth5yUB+lgcAAABHEH7Ua8wMBc4JOEAAABQLCAAAZjx4SlBhtdfvAAwHxAP7AZ34DAAUWGRpAQUVPUFFSU1RZ6aAGAAABCAAAHwsAAD0NAAAKEgAA0BQAABkWAABRFgAAHxkAAKIeAABgAgAAAg==") \ No newline at end of file diff --git a/api/search.index/7298551ea9.js b/api/search.index/7298551ea9.js new file mode 100644 index 00000000..facb8a98 --- /dev/null +++ b/api/search.index/7298551ea9.js @@ -0,0 +1 @@ +rn_("FYHDAAAUrhp3EEHGAAC2GI4ZjxmQGUHDAAAUFrcXsxq0GkHBAABBEHMQdBB1EOMA/UChBsMAABj0wwAAGPXIAAAdL+VNCgAA1QsAAMgTAAAJFQAA4BkAAPMZAAAQIBH7AmV0") \ No newline at end of file diff --git a/api/search.index/73f7c30554.js b/api/search.index/73f7c30554.js new file mode 100644 index 00000000..c5e5dca4 --- /dev/null +++ b/api/search.index/73f7c30554.js @@ -0,0 +1 @@ +rn_("QcAAAOMKiheNGTweJQHAAAAUdh90MBylHSMFwALAxgAAHzXHAAAfEfJtGQAANwITAMABoMMAABYlwgAAGdnyiBAAALUM8wACZXMBIRIAAA==") \ No newline at end of file diff --git a/api/search.index/7410cf7cae.js b/api/search.index/7410cf7cae.js new file mode 100644 index 00000000..cef19a17 --- /dev/null +++ b/api/search.index/7410cf7cae.js @@ -0,0 +1 @@ +rn_("BQLBAAAZoh0VnBpxdAEAkMkAAB+460MKAABGCwAAFBIAAC8SAABLFAAA1xQAAFsZAAByGQAAFB8AAE0fAABSHwAAzR8AAEAQAgAAADvAECTGAAAe/ysDwQAAGRLFAAAdMWFpcg==") \ No newline at end of file diff --git a/api/search.index/7451a083a9.js b/api/search.index/7451a083a9.js new file mode 100644 index 00000000..99eb4f01 --- /dev/null +++ b/api/search.index/7451a083a9.js @@ -0,0 +1 @@ +rn_("RQHCAAATSB1uAB1GHl0fXx8BQeZADQAAlBgAAA4ZAAAvGQAAPRwAAJQfAABtIAAAADAwAAUCzAAAFpsgF6IgY3AhxQAAARU4HCHGAACPG3UcBQLGAACkkx4WIx9jZSsCzAAAH31scqHFAACsFxuV0wAAIKHFAAAeTgUCwAAAFb4ZEkobb3IFi8AAABIoHxQeHRFfEAUCwAAAMB0NHg1HGxW1G3B3ocQAAJgYJwUA/Q3BAcAAABDHxwAAH1rCAAAasMIAAB2igiIJAABRxQAAqReqF6sXjRuOGzWCxQAAFGwfpxeoFzMd+wJob+c8AP5AS6YZyQAAHVjCAAAY58oAACBqzAAAH0LJAAAfJu3LCAAA5xEAAKIUAACoFAAAHBUAAD8ZAACyGwAAVxwAANkcAAA0HgAAOR4AAJIeAADwHgAAix8AAEEAAAAAAAD7/RCAAw==") \ No newline at end of file diff --git a/api/search.index/746daaab13.js b/api/search.index/746daaab13.js new file mode 100644 index 00000000..5b5e993d --- /dev/null +++ b/api/search.index/746daaab13.js @@ -0,0 +1 @@ +rn_("ocUAAAEapYPAAAAS2hkWUR7NFAUCwgAAFaoeFFAeY3MFisAAABj5HhsCH6UCwwAAFJIdFr4ec3eAGRsCxwAAHvhhbPMDwhZA4ewRAADHGQAAEPMBAW4B9AcAACHEAABQGRIfIwMCwgAAGKNtb+PaCwAAYxQAANsZAADdHgAAYRA7wAgOwwAAEDvIAAAclA==") \ No newline at end of file diff --git a/api/search.index/746eb3c1a2.js b/api/search.index/746eb3c1a2.js new file mode 100644 index 00000000..3e3b32db --- /dev/null +++ b/api/search.index/746eb3c1a2.js @@ -0,0 +1 @@ +rn_("FQHEAAAUKB9kshuhxQAAxhzzggJwcQUCwQAAFOsXFfoYc3ShxQAA+RlrwAEoxAAAIJAFwMEAoQAAGjodGSsdEo8dBQLBAAAU9hxhaRBqEPYR9xH4EfkRbHIFAsAAABL1EReBG2VnJYTFAAAUnh95HZ0gJYTEAAAUYx8IHZQg84ECbHJRwwAAuxq8GmMcgyCIIPOBAnJ0G4rGAAAgVxUBwAAAJWYdwB92YRwhxAAASRskH6HGAADEHDHBAABBDaEYPh4FAsAAAFO0G6ocqxysHGQgEHEgb3NnBwD9FgEHwgAAHRLDAAAfjOshDQAA4hQAAL8YAAB+HAAAmxwAAFcdAABeHgAAuB4AALseAAD6HgAAZR8AAM8fAAAQABAAEBAVAcYAABCrHnMgHRMCAsEAABtvcHXhyhwAAKUeAABCNQHAAAAXxh9kaBDTGQQf84GGKwLJAAAfg2hvMcUAAAYVgheDF/c7AP01gU7MAAAfx+qdCAAAqQ0AABcSAABIFAAAHRUAAJMXAABqGAAAgRgAAOIYAADmGQAArR8AAAIBMAAAAA==") \ No newline at end of file diff --git a/api/search.index/75320e0f10.js b/api/search.index/75320e0f10.js new file mode 100644 index 00000000..951a034c --- /dev/null +++ b/api/search.index/75320e0f10.js @@ -0,0 +1 @@ +rn_("BQLBAAClmhcoTRx+IHByIcUAAEQWSR4FAsEAACanGWYgFSUZYXABROMZEgAAQBYAAEgeAACQHgAAMwAbAscAAB6obHIBReMhGQAAcBsAAJYfAABZIAAAMwAhwwAAPxZtHPOBAml2BQLGAAAYRx8ViR5jZiuKxQAAGSAhyAAAphlNHvv9LIAL") \ No newline at end of file diff --git a/api/search.index/75433e3827.js b/api/search.index/75433e3827.js new file mode 100644 index 00000000..19b28dba --- /dev/null +++ b/api/search.index/75433e3827.js @@ -0,0 +1 @@ +rn_("BYPAAAASshoUixdFAsIAABV+GxqoH3B0BBIFEl0dXh0hwAAAbhCbHvsCcnSjAP0BiQjJAAAdmsUAABixxgAAH4P1ehgAAAEAiwRGAV4A") \ No newline at end of file diff --git a/api/search.index/75b526e9ac.js b/api/search.index/75b526e9ac.js new file mode 100644 index 00000000..fc0550ee --- /dev/null +++ b/api/search.index/75b526e9ac.js @@ -0,0 +1 @@ +rn_("AQADxgAAIGXGAAAeuMUAACB8ZXJ0480XAAAzHAAANB0AAGsgAAAjMKHBAABZHPMCAmlz5LEQAAABEgAAphcAAEMdAABbHgAAAAMAockAAMQcYcAAAK0UVBZVFlYWVxZYFsX9wVIBAgAAFXscFTAdFm4eE+MZF8ceFRb7wAaE") \ No newline at end of file diff --git a/api/search.index/75fe028320.js b/api/search.index/75fe028320.js new file mode 100644 index 00000000..e00b95f6 --- /dev/null +++ b/api/search.index/75fe028320.js @@ -0,0 +1 @@ +rn_("IcAAAGUYqRoFhcAAABexHheyHiHBAAC9GBEf8wEDMm1z8+kLAAAoBsMFBQLBAAAWDB9Wfh5/HoAegR6YIG5w+wJwcg==") \ No newline at end of file diff --git a/api/search.index/760f70384f.js b/api/search.index/760f70384f.js new file mode 100644 index 00000000..d71ca202 --- /dev/null +++ b/api/search.index/760f70384f.js @@ -0,0 +1 @@ +rn_("pcDAEkAAALVyHBQfEhNZEOUHAQCIygAAHw3ivAgAAGsNAADkEQAAUCChxAAAAhk3AQDEBRjGAAAcoMMAABkE5dIIAACPCwAATRkAAFAdAABBHgAAWCAAAAIAAA==") \ No newline at end of file diff --git a/api/search.index/762e754ea1.js b/api/search.index/762e754ea1.js new file mode 100644 index 00000000..0abcb423 --- /dev/null +++ b/api/search.index/762e754ea1.js @@ -0,0 +1 @@ +rn_("NQHBAAAWAR939xn4GV8cocoAAG0f84UCY2UFAsAAAChBHlggsoUbb3mhxgAA+RkFAsAAACGhGD4eJmYdwB9ldBcBAP0QgA7FAAAc9swAAB/HwwAAHY/jqQ0AAEgUAAAdFQAAahgAACEwpQLFAAAWYR4WYh5td8gZpQHGAAAW0R93iB0FAsAAABUFHxEEFWFmAUDhtBQAAEsbAAAW+wJkbgUCwAAAI3EcLh4anx9ucKHFAAB7GRUBxwAAEJwgcxEdOwPEAAAen2Fob/ceAP4ARSwLyQAAH2Q6MAAAAQAAAAAAIwAQAAAAPAvWC9cL2AvZC/EM8gzzDNMU1BQnFigWdxauGN0ZoxpJG3cbeBt5G6IboxtnHMMcIR1OHZMdlR1EHkUenB4dHyQfcB9yH10g") \ No newline at end of file diff --git a/api/search.index/76442d1bd2.js b/api/search.index/76442d1bd2.js new file mode 100644 index 00000000..f88c5c04 --- /dev/null +++ b/api/search.index/76442d1bd2.js @@ -0,0 +1 @@ +rn_("BQLEAAASvx6nZR9pcgEEAcUAAB8AcuPwGQAAUx8AAH0fAACoIAAAFAABAgHFAAAeX3Li2hgAAOEeAACgIAAAFAD7iSHFAABVGaweocYAAIUcFQHEAAAWrR5yhx83FQD9FqAdxAAAHJLFAAAbk8UAAByH0AAAIIn1qRgAAIgDHgEJAEIABYnAAAAS2h4USx8rAsEAAB0aaXTBwgAASh1lA8EAABa2HxMbHxY3H2Rpd5EbkhvUHNUc1hzuHrOD/RCBAsYAAB+GE4ECwwAAHrZwcUHOAAClH7UfhiCSIAEGAcUAAB82cuSQGwAAjh8AAIcgAACiIAAAryAAAAQAAAUCxgAAEZEepEkfbXRrA8EAABTQbG9ysccAAIgcJQHBAAAXaB9zIh0iHvsCZnAFicAAABZpHhR0GTHDAACiFzIZbB4FAsEAABSLHRVeHnFz8wEBcuKhFwAAthwAAHkgAAADACsCwQAAHKFkZqUBwAAAoPMRc0QO+wJkbqHFAAClHEX9wwOAAgAAJTsfUiAzwR7IH18gI4Edwh4VPB/JHhAfdSCEIDHEAAB9G6QciB/7AmN184UCYXAFAsMAABR8GxV6H2VmQcEAANkY0RxIH4kfMwH9ECEByAAAHHbKAAAdgOZOCwAAHRIAAPQUAABLFgAAvRcAANwYAADFHgAAMRABABsC4AAAGOFuchWEwgAAaJYdlx2YHVMgVCBVIPcMKwLJAAAfp2lyNQLEAAARMx4T2B5pdO4Z7xmbIBsCwQAAF4FudSsDyQAAHl3AAAAX1mFvdBUBwwAAFn0dchwfBQLFAAAWMx8TWR5obwEA/sBX9wFkKgLVvDeuV4rnNTJoSWp1dqx5oF+6WYzmPmnuUYFuKiCTFCZ2eAKOASTrbdlDKanvgwLieeVzSpxSk1IMzCT6Z150Rhi2egtCFjs6MAAAAQAAAAAAHgAQAAAA9QcAEgwSFhUXFRgVORbjGOQY1BnVGdYZZhtnG24bLxxHHWEdYh1jHW4dbx16HUMe6R7qHuseIB9QH70fvx8=") \ No newline at end of file diff --git a/api/search.index/76558276c5.js b/api/search.index/76558276c5.js new file mode 100644 index 00000000..b759754f --- /dev/null +++ b/api/search.index/76558276c5.js @@ -0,0 +1 @@ +rn_("JQLEAAAWth4wMRxYHZodcnMAElAfZQHAAAAZdht0PgnMHs0ezh7PHp8gFQHNAAAQrR9zfx0FAsAAACkjHRMfHTkfbHIBQ44xEgAAAUGOLg0AANHEAADIFwUDxgAAFgMfJX4drB8YcR9lc3T7AmZyFz8A/S2hB8MAABUMxwAAHw7HAAAboeO/BwAA/AcAAJEZAABwHQAARAA=") \ No newline at end of file diff --git a/api/search.index/76b47c5e4b.js b/api/search.index/76b47c5e4b.js new file mode 100644 index 00000000..480efbe1 --- /dev/null +++ b/api/search.index/76b47c5e4b.js @@ -0,0 +1 @@ +rn_("BQLAAAAbzx9CvRSrGW8ccBxudBUCxAAAFWgeo50bY2x7HRMDAswAAB+hYWMB9AwAACUBwAAAFHYfdDAcpR0jA8ACwMYAAB81xwAAHxHybRkAADcCGwLgAAAHymZnBQLAAAAREh0SBxVkb6HCAAACGRUBwgAAF2gfcyIdI4ECzQAAIF9nb/sCY3SfB/5AQBJMxAAAGc/FAAAVBw==") \ No newline at end of file diff --git a/api/search.index/772204f00b.js b/api/search.index/772204f00b.js new file mode 100644 index 00000000..01ffb5d6 --- /dev/null +++ b/api/search.index/772204f00b.js @@ -0,0 +1 @@ +rn_("BQLAAAAZJx0lmBy+HmlyBQPAAAASmhkz/RmSHdAeFNUXaXR1IcMAAPUcgR8FAsAAABJfHR09H210BYPAAAASshoUixdFAsIAABV+GxqoH3B0BBIFEl0dXh0hwAAAbhCbHvsCcnSjAP0BiQDJAAAdmsUAABix4noYAAAGHQAAqh4AABAABQLBAABz1xTYFNkUFB9NH1IfzR+ikx5udWcbAP5AR1ARwwAAHXDFAAAfI8MAAB7w6QMIAAAqEgAAGxUAAHsWAABJGQAAgBkAAJ0eAAD4HgAAWx8AAHwgAAAgAQEAAAFB+HYYAAABANcAWQCmAt4BOwIYACOBAsMAAB0taWwzAAPDAAAeWmFlbfJECQAABgE=") \ No newline at end of file diff --git a/api/search.index/7748fdd778.js b/api/search.index/7748fdd778.js new file mode 100644 index 00000000..671201b0 --- /dev/null +++ b/api/search.index/7748fdd778.js @@ -0,0 +1 @@ +rn_("AQQBxQAAHwBy4/AZAABTHwAAfR8AAKggAAAUAAECAcUAAB5fcuLaGAAA4R4AAKAgAAAUAPuJS/0QIAzEAAAcksUAABuT0AAAIIk=") \ No newline at end of file diff --git a/api/search.index/77a0b51824.js b/api/search.index/77a0b51824.js new file mode 100644 index 00000000..3c48075b --- /dev/null +++ b/api/search.index/77a0b51824.js @@ -0,0 +1 @@ +rn_("BQLBAAAUfBsVeh9lZgUCwAAAEpIYF48gZXkVAcYAABCrHnMgHRUBxwAAEEAfc+wZxwEA/SyACsMAAB8uygAAIIXBAAAbb+YoCwAAmhgAAGwcAADKHAAApR4AAKkeAACzIAAAEAQgAGv9YIEAwAAAF4/DAAAfxg==") \ No newline at end of file diff --git a/api/search.index/7941c32b77.js b/api/search.index/7941c32b77.js new file mode 100644 index 00000000..572cb689 --- /dev/null +++ b/api/search.index/7941c32b77.js @@ -0,0 +1 @@ +rn_("BQLAAAATAxUYYB1jZRUBxAAAMDoeOx6KHnO4FyuDwQAAFmMHDAD+AEXwB8cAABz1xwAAHJDDAAAeocUAABjixAAAGUrDAAAYnsIAABZkyQAAHJTjLQkAAP8KAACtGQAA8hwAABMA") \ No newline at end of file diff --git a/api/search.index/7994d89acd.js b/api/search.index/7994d89acd.js new file mode 100644 index 00000000..e8b917fd --- /dev/null +++ b/api/search.index/7994d89acd.js @@ -0,0 +1 @@ +rn_("FQHGAAAYwx90hh8hxgAAHx/UHwWJwQAAYuEe4h7jHuQe5R6gIGRTH1QfVR9WH1cfqCBhxwAAjh+PH5AfkR+SH68g84YCcHP7A2xvcg==") \ No newline at end of file diff --git a/api/search.index/79cb149549.js b/api/search.index/79cb149549.js new file mode 100644 index 00000000..5cf84029 --- /dev/null +++ b/api/search.index/79cb149549.js @@ -0,0 +1 @@ +rn_("BQLAAAAVGBkW4B5sbwcEAf0VsQPEAAAausUAAB0yxQAAHQnHAAAcQckAAB/CyAAAGefKAAAcf8MAAB1aAdgHAAA=") \ No newline at end of file diff --git a/api/search.index/79d9a39314.js b/api/search.index/79d9a39314.js new file mode 100644 index 00000000..59ec0609 --- /dev/null +++ b/api/search.index/79d9a39314.js @@ -0,0 +1 @@ +rn_("YcYAAAwd/B5qH2sf0R/SHyHEAABXHlAg8wMCc3fo1hQAAKwXAADQFwAA5BkAAI4cAACIHQAAjh0AAGEeAADZHgAAAQARAAA=") \ No newline at end of file diff --git a/api/search.index/7a938b8bae.js b/api/search.index/7a938b8bae.js new file mode 100644 index 00000000..dacca085 --- /dev/null +++ b/api/search.index/7a938b8bae.js @@ -0,0 +1 @@ +rn_("UcIAAD0cPhw/HEAcbSABROTjGAAA1BkAAG4dAADpHgAAvR8AABISABWExAAAJPYeiSAYHRMAA9UAACCxxgAAHXN0dXeC6REAABUBxgAAoAMdcwAaBQLCAAAYYR8TpB9jbbcDAP4ACGAHxwAAHZHk6QgAACQNAAD2GAAApx8AAJAgAAAwEAA=") \ No newline at end of file diff --git a/api/search.index/7b8020e512.js b/api/search.index/7b8020e512.js new file mode 100644 index 00000000..0effe49b --- /dev/null +++ b/api/search.index/7b8020e512.js @@ -0,0 +1 @@ +rn_("FYTEAAAk9h6JIBgdocIAAIAQMcUAANsZ3BndHjcCAP2AiV7CAAAaC8IAABoMxwAAHC/VAAAgscYAAB1zwwAAEhDlkgsAALMQAADpEQAAAx0AAG8eAACrHgAAABEA") \ No newline at end of file diff --git a/api/search.index/7c74e8334e.js b/api/search.index/7c74e8334e.js new file mode 100644 index 00000000..5dc2b106 --- /dev/null +++ b/api/search.index/7c74e8334e.js @@ -0,0 +1 @@ +rn_("occAALseocUAALkZQcYAAKUftR+GIJIgFQHEAAAZVyBz4Bj7AnB24wD9AMQBwQAAHrn/zwsAAAEAAQDLC4oBcgL7AG4AAQArAMIBxAAJAAkAowABQuWMFwAA1xcAACAeAABdHgAATh8AAIogAAAEAAChxgAA8xkVAcAAABCAF3PaFPsCbXP7A2VpeQ==") \ No newline at end of file diff --git a/api/search.index/7ca0b9ae68.js b/api/search.index/7ca0b9ae68.js new file mode 100644 index 00000000..207b8ec0 --- /dev/null +++ b/api/search.index/7ca0b9ae68.js @@ -0,0 +1 @@ +rn_("ocgAACweBQLGAAA0bh1vHb0fUj0cPhw/HEAcbSB0dTHIAACCHoMemSAFAsgAABKKIBDvHml5U4MExQAAH7zBAAAZnWlxc3UFAsEAABBQHjFUFNgcPh9zdfsCYXIFAsgAABUUHxTQHmNzMcgAAGkbHh8hHwUCwAAAWb8cwBzBHMIceyARahxteqHFAACHHmUBxAAAEGoYcycWKBZ3FqIboxtOHQUCwgAAEEceIs4ZZyBzdQX8wgBUAAAesCAdtyAXjSD7Aml268AC4MoAACBv1w8A/ZAZTM4AAB9cAdseAAA=") \ No newline at end of file diff --git a/api/search.index/7d1346ea91.js b/api/search.index/7d1346ea91.js new file mode 100644 index 00000000..28536dca --- /dev/null +++ b/api/search.index/7d1346ea91.js @@ -0,0 +1 @@ +rn_("BQLAAAATAxUYYB1jZRUBxAAAMDoeOx6KHnO4FyuDwQAAFmMFAsAAABXiGBqQIHBzMcQAAJUclhx8HxcZAP5ARfAHxwAAHPXHAAAckMMAAB6hxAAAGUrDAAAYnsIAABZkyQAAHJTjLQkAAP8KAACtGQAA8hwAABMA") \ No newline at end of file diff --git a/api/search.index/7e4b4c401c.js b/api/search.index/7e4b4c401c.js new file mode 100644 index 00000000..dedd59ec --- /dev/null +++ b/api/search.index/7e4b4c401c.js @@ -0,0 +1 @@ +rn_("AQIBwgAAH6Jk5OwXAACRGAAAmBgAAJsaAAB2HQAAABAgFQHDAAAQdB1zPR4bAsQAAB9HbXD7Am11KwPHAAAdcMwAAB7TYW9y") \ No newline at end of file diff --git a/api/search.index/7f3e2c3a62.js b/api/search.index/7f3e2c3a62.js new file mode 100644 index 00000000..8b989a40 --- /dev/null +++ b/api/search.index/7f3e2c3a62.js @@ -0,0 +1 @@ +rn_("UcAAACIWIxZZFloWkR4Fg8AAACQUHWAgo/oRBQLDAAAUhh0Uhx1vdFvABITHAAAeIVHJAABwG3EbchtzG1kgpQHEAAAW0R93iB07xAAk4AAAFN4/Af0RgQLAAAAU38YAABkw") \ No newline at end of file diff --git a/api/search.index/7f539f6bc6.js b/api/search.index/7f539f6bc6.js new file mode 100644 index 00000000..a169f9f3 --- /dev/null +++ b/api/search.index/7f539f6bc6.js @@ -0,0 +1 @@ +rn_("occAAEUfBQLAAAARcxkXaCBmZysC4AAAGaRkbvMEhOJTHAAAwB4AAG4gAAAwADHEAACuGMMcXSAJAtAAACC1wQAAHktpdjHBAADnGekZdhz7A2Vyc68C/RQETMIAABlDxwAAHkDEAAAXlg==") \ No newline at end of file diff --git a/api/search.index/7f6f724f8f.js b/api/search.index/7f6f724f8f.js new file mode 100644 index 00000000..02721676 --- /dev/null +++ b/api/search.index/7f6f724f8f.js @@ -0,0 +1 @@ +rn_("BQLBAAAXeR6pxhxmcwUCwAAAFHwgF3YbY3IbAsgAAB2cb3IhxwAAvxi4HqHFAAB8GKHMAAAsHiHGAAB0H2UgYcYAAP4Z/xlrHIIegx6ZIAUCwAAAE6UgEDEdb3N7wAIlwgAAHz2h0AAARR8F/cABQAUAAKR6GBM6Fq3RHhFWHlHHAAAlHiYeJx4oHowgAQD9ftAPFNQFxl44jVhpyQdiTPV9blZFnH/IAAAfPSaviwhIwQAAEK/FAAAZ5g8vVlhmA6IQtlzHAAAXmTJDzPYyco28K3M6MAAAAQAAAAAARwAQAAAAowikCKUIpginCKgIqQiqCKsIPgnjCoIQgxCEEIUQIhYjFlkWWhaGFocWgBeKF80XzhfPFyoZKxksGY0Z8hkzHDQcNRw2HDkcOhw7HDwcvxzAHMEcwhzQHP0cEB00HTUdNh03HVEdUh2ZHaMdPB53HngekR6ZHqEezB7NHs4ezx7eHjgfbx9rIGwgeyCWIJ8g") \ 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/9bf64b714b.js b/api/search.index/desc/9bf64b714b.js new file mode 100644 index 00000000..b333de8b --- /dev/null +++ b/api/search.index/desc/9bf64b714b.js @@ -0,0 +1 @@ +rd_("AoGL entry points, resolved once.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 valueCmAn HLS playlist. The webview attaches hls.js (or Safari\xe2\x80\x99\xe2\x80\xa6lTimer is offAfAdd items to the queueBfMake a GET request to the Jellyfin APIdmpv.AiCreate 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\xa6AoOpen at the beginning, playing.CiCreate a new TauriEventEmitter with the given app handle.AhCreate a new MPV backendCdCreate a render context over an existing mpv handle.CnBuild 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 URLBbThe URL (or loopback URL) to open.oNo media loadedDeNothing loaded. close() must reach this, and must be \xe2\x80\xa6BdNo 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.CmNo audio device, no window. What conformance uses, so the \xe2\x80\xa6BhReal audio and video. What the app uses.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.BhDraw the current frame, if there is one.BjEmit a player status event to the frontendAoReturns the argument unchanged.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000BaCalls U::from(self).00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000AfCurrently 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-005CmResolve every entry point, or none \xe2\x80\x94 a partially-loaded \xe2\x80\xa6B`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 itemDhPresent req.selection, beginning at req.start.ClLoad, play, then seek \xe2\x80\x94 the sequence every caller used \xe2\x80\xa6BnOpen 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-005CjSeek to an absolute position on the item\xe2\x80\x99s own timeline.BcVideo 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\xa6DkReached the end of the item by itself. Distinct from Idle, \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\xa6BbWhat an engine is doing right now.C`Represents a database query that can be executedAiQuieter output (-23 LUFS)BkLoaded and able to play, but not advancing.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.BbStop and release the current item.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 titleChHuman label (\xe2\x80\x9c8 Mbps\xe2\x80\x9d). Lives in Rust beside the \xe2\x80\xa6CnHuman 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 processorDhWhere to begin. Duration::ZERO means the start of the item.BcStart 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\xa6BlThe engine renders pictures, not only sound.CaCustom 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)BjHow the engine should talk to the machine.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\xa6CcState the event thread writes and the caller reads.ClA colour-renderable framebuffer mpv draws into, sized to \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 audioCcStart drawing mpv\xe2\x80\x99s video underneath the webview.A`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\xa6CcDrop the live surface, if there is one. Idempotent.AmSecondary line (\xe2\x80\x9c1080p\xe2\x80\x9d).ChSecondary 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 queueEgRender the current frame at width x height, returning the \xe2\x80\xa6AcCurrent 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 volumel0.0 \xe2\x80\x93 1.0.AjNew 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/bufferedDhAn open is in flight. Position is not yet meaningful; a \xe2\x80\xa6A`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\xa6CeThe engine. One contract, so the controller stops \xe2\x80\xa6ChThe source\xe2\x80\x99s own bitrate, when the server reports one.CkTauri 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 itemBmFree everything, with the GL context current.CbReturn volume control to the local device speaker.ClDismiss/clear current session (user action) Transitions: \xe2\x80\xa6CiWhether mpv should decode and draw video in this process.BmWhether 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)CiCreate the GL context and the mpv render context over it.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\xa6BfTauri commands for offline data accessAjBuffering progress (0-100)CjPinning commands - protect an item\xe2\x80\x99s cached metadata \xe2\x80\xa6CeThe rung of the ladder this stream was built against.CiResolve a GL symbol the way libepoxy actually exports it.CgWhere 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.CmAn engine that renders through the webview element, where \xe2\x80\xa6CkMobile data \xe2\x80\x94 never acceptable when wifi-only is enabled.CcDatabase connection wrapper with thread-safe accessAjDatabase query result typeBdThe opt-in for native desktop video.CdCentre 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 contextBlBegin playing as soon as the engine is able.CcWhether 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 secondsDgNone while unknown \xe2\x80\x94 a live stream, or an item still \xe2\x80\xa622CfTotal duration \xe2\x80\x94 live, or the last one observed. \xe2\x80\xa6AcDuration in secondsAgGet 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 modeClThe old trait has no notion of \xe2\x80\x9copening\xe2\x80\x9d, so this is \xe2\x80\xa6BhWhether 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\xa6DhWhether seek can be expected to land. False for live edges.B`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.BjOne coherent read of the engine\xe2\x80\x99s state.DeDecide which span to send for a Range header (or its \xe2\x80\xa6CgTear down in the order DR-232 requires, with the GL \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\xa6CkA file already on disk \xe2\x80\x94 a completed download, or the \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.ClThe rendition actually negotiated \xe2\x80\x94 what the viewer is \xe2\x80\xa6BeError types for repository operationsCnThe query appears mid-word \xe2\x80\x94 \xe2\x80\x9cparks\xe2\x80\x9d in \xe2\x80\x9cSparks of \xe2\x80\xa6ClThe server is re-encoding. The only case where a bitrate \xe2\x80\xa6BmHow the bytes of a chosen stream are fetched.DgSome 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.ClWhat this media source can offer, for the quality picker \xe2\x80\xa6C`Evict least recently used items to free up spacejExoPlayer.CgPushing 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 userBdWhether mpv has a new frame waiting.BkWhether the engine currently holds an item.AnWhether playback is advancing.BlTrue = 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).DgThe negotiated rendition; None when direct-playing the \xe2\x80\xa6CmResolve app state and drain. Shared by the reconnect hook \xe2\x80\xa6BlDrop blanks and duplicates from the id list.ChWhat to open, and how \xe2\x80\x94 transport included, so the \xe2\x80\xa6BnWhat to open, and how. TRACES: UR-079 | DR-225CkWhat the backend actually opened, so the UI reflects it \xe2\x80\xa6ClWhat to open, and how \xe2\x80\x94 already negotiated against the \xe2\x80\xa6DiSeries 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 bytesCkThe old trait has no mute. Folding it into volume would \xe2\x80\xa6CbReplace 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\xa6CgHow this item\xe2\x80\x99s stream is fetched, as the backend \xe2\x80\xa60BdHow to open stream_url.DaHow to fetch it. Replaces the .m3u8 substring check.CdWhether 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)CjThe source file is served untouched. No server CPU, no \xe2\x80\xa6AlEncryption/decryption failedB`Stop at the end of current trackCnOne row of the series episode listing used when queueing a \xe2\x80\xa6EeGL enum for gdk_cairo_draw_from_gl\xe2\x80\x99s source_type. GDK \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\xa6CkThe mpv handle of the backend this process created, for \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 statusCfThe track the server serves when the client pins none.AkCheck 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\xa6BgA selection for a file already on disk.DiResolution ceiling, when one applies. None preserves the \xe2\x80\xa6DfResolution ceiling that suits the bitrate, or None to \xe2\x80\xa6mType of mediaBeMediaPlayer over libmpv.Cjmpv\xe2\x80\x99s render API into a framebuffer we own (UR-080 / \xe2\x80\xa6ChSet 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 playB`Anything that can present media.CmA 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\xa6AmA request to present an item.B`Error type for player operationsCkPlayer state machine (6 states: Idle, Loading, Playing, \xe2\x80\xa6CcHint for adjusting poll frequency based on UI stateCkA single progressive HTTP resource, seekable by byte range.ClWhy 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 imageBlAudio codec the server was asked to produce.AjBuild 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 stringCnSet by mpv\xe2\x80\x99s update callback (on an mpv thread), cleared \xe2\x80\xa6AoExtracts item_id from operationAcGet current sessionAlGet a specific session by IDBoWhether we\xe2\x80\x99re currently checking connectivityBeGet the Jellyfin item ID if availableCkLast size we logged, so a size change re-reports rather \xe2\x80\xa6DjMark 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 databaseClTotal bits per second the stream may use, when a ceiling \xe2\x80\xa6CmTotal 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 modeBoTell mpv the frame reached the screen. See [\xe2\x80\xa6BeRelevance ranking for search results.DdSort key for a season number. Specials sort after every \xe2\x80\xa6Djseek_offset carries the position to RESUME AT, not a base \xe2\x80\xa6AeSet 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 media0BlVideo codec the server was asked to produce.AeVideo width in pixelsBgCached person info returned to frontendCjWhat an engine can do, so callers adapt without naming \xe2\x80\xa6CmThe container is repackaged but the codecs are copied \xe2\x80\x94 \xe2\x80\xa6AlInformation 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 optionsBmThe one live surface, on the GTK main thread.CiAn 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 informationCjWhat the server is doing to the source to produce this \xe2\x80\xa6CePlayback 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 trackCbHTML5 must reload its element with this selection.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 downloadsCjEverything the draw handler needs, shared with the GTK \xe2\x80\xa6AoBackend type for video playbackCnA live video surface. Dropping it tears the render context \xe2\x80\xa6BlGet 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 thumbnailsDkThe boxed clone of frame_ready handed to mpv, reclaimed on \xe2\x80\xa6BkWhat the engine currently rendering can do.CjDeclared at construction: this wrapper is generic over \xe2\x80\xa6BlServer-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\xa6DhThe MediaPlayer contract: one API, interchangeable engines.CdA loopback HTTP server for locally downloaded media.CdMedia item from either local queue or remote sessionCgWhether this process renders video natively \xe2\x80\x94 one \xe2\x80\xa6AdNext episode to playCjJellyfin writes the same GUID both dashed and undashed \xe2\x80\xa6CeThe partial-download sidecar for target.CeA seek that arrived before MPV had a file to seek in.BfThe URL or path an engine should open.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 operationDbOne rung of the quality picker, as it applies to this \xe2\x80\xa6CgBudget 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 index1DfA seek that arrived while opening. Applied on FileLoaded.ClGet 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\xa6CnCreate or resize the framebuffer. Reused across frames \xe2\x80\x94 \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\xa6DiA MediaPlayer over the old PlayerBackend trait.CnLoad 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\xa6Cnmpv\xe2\x80\x99s update callback. Runs on an mpv thread, so it does \xe2\x80\xa6CjCache-first query: try cache, fall back to server on miss.CeWhat the server is doing to the source to produce it.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\xa6CgThe native video surface mpv renders into (UR-080 / \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 settings0CiAudio settings (EQ, normalisation, gapless) are honoured.BaClassify 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)CkTrue when this rung\xe2\x80\x99s ceiling is at or above what the \xe2\x80\xa6CgRestrict 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 URLCmThe source\xe2\x80\x99s own bitrate, when the server reported one. \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 statisticsCkEverything a player backend needs to open a stream, and \xe2\x80\xa6CfWorker 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\xa6CeA selection for an item already sitting in the queue.BkBuild 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.CnThe media source this selection is for, so a later re-open \xe2\x80\xa6DkCleared by close(), so an open still in flight cannot come \xe2\x80\xa6BjTurn 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\xa6CmThe transcode identity the server keyed this job by, when \xe2\x80\xa6AeCreate a new playlistAaDelete a playlistAaRename a playlistChRecord a position alone, e.g. straight after a seek, \xe2\x80\xa61CmWhat the renderer that will actually decode video on this \xe2\x80\xa6AiResume 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 contextCjmpv\xe2\x80\x99s render context plus the framebuffer it draws into.CmOnline 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\xa6CgEverything the UI consumes, read as one coherent value.CcCentral 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 libraryChWhat mpv calls to find GL entry points. Same rule as \xe2\x80\xa6BnGet 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 bytesCjBackend-owned stream selection (UR-079 / DR-225). What \xe2\x80\xa6BjGet 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\xa6DlThe viewer\xe2\x80\x99s durable bandwidth ceiling \xe2\x80\x94 the device \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 transcoding0CkWhether the server is spending encoder time on this stream.0ClThe 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 activeChThe registered handle, or null if no MPV backend was \xe2\x80\xa6BhRemaining 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\xa6CiThe durable device default, ignoring any per-playback \xe2\x80\xa6C`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\xa6CiOmitted so the server resolves the source\xe2\x80\x99s default \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\xa6DfSeconds reported by an engine, as a Duration, without \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.CkChannels the device\xe2\x80\x99s audio route can actually voice. \xe2\x80\xa6CmThe channel cap for this device, reading the platform\xe2\x80\x99s \xe2\x80\xa6BfMove a playlist item to a new positionClAsk the server what it will do with this item, under our \xe2\x80\xa6CbNormalize 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 PlaylistItemId8CjGet 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)BmApply EQ, normalisation and gapless settings.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 IDCcSubtitle tracks can be selected without re-opening.CjTransfer 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 logicDkConstruct the tauri-specta command builder. Shared by run()\xe2\x80\xa6ChA 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\xa6BiAsk to be told when a new frame is ready.CmStart 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\xa6CeThe direct-play / direct-stream / transcode decision.AoDelete 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\xa6ClDecide what stream to play, and describe it fully enough \xe2\x80\xa6ClDecide what stream to play and describe it fully \xe2\x80\x94 the \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 accessC`Audio tracks can be selected without re-opening.CaDecide 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.BkThe stream selection for a downloaded file.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\xa6CkSet the durable device default. Applies to every stream \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.CmThe container can be repackaged without re-encoding \xe2\x80\x94 a \xe2\x80\xa6CjDelete 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\xa6DgThe ceiling in force for the playback happening right now, \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 modeBnThe per-playback override, if one is in force.BlDestroy 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 sessionDeWhether the renderer that decodes video on this platform \xe2\x80\xa6CmReport 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\xa6DeA server-side transcode can be seeked without re-opening \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\xa6CkBuild the quality ladder as it applies to a source of a \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 checkCmThe ceiling that actually applies to a stream opened now: \xe2\x80\xa6CnFetch 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\xa6CnMove this playback to a different ceiling without \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\xa6CkDrop any per-playback override, returning to the device \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.ChDecide what stream to play for a video, and describe it.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/049a7f110a.js b/api/search.index/entry/049a7f110a.js new file mode 100644 index 00000000..c3c60531 --- /dev/null +++ b/api/search.index/entry/049a7f110a.js @@ -0,0 +1 @@ +rd_("B`[6270,13,5756,5756,7551,341,0,0]B`[6270,13,6453,6453,8147,341,0,0]B`[6270,13,6453,6453,7568,341,0,0]B`[6270,13,7500,7500,7992,341,0,0]B`[6270,13,2794,2794,7321,341,0,0]B`[6270,13,2794,2794,7454,341,0,0]B`[6270,13,2794,2794,8146,341,0,0]B`[6270,13,2794,2794,7784,341,0,0]B`[6270,13,2794,2794,7554,341,0,0]B`[6270,13,2794,2794,6054,341,0,0]B`[6270,13,2794,2794,6636,341,0,0]B`[6270,13,2794,2794,5378,341,0,0]B`[6270,13,2794,2794,6078,341,0,0]B`[6270,13,2794,2794,6638,341,0,0]B`[6270,13,2794,2794,7055,341,0,0]B`[6270,13,2794,2794,7056,341,0,0]B`[6270,13,2794,2794,7863,341,0,0]B`[6270,13,2794,2794,7304,341,0,0]B`[6270,13,2794,2794,7303,341,0,0]B`[6270,13,2794,2794,7325,341,0,0]B`[6270,13,2794,2794,8039,341,0,0]B`[6270,13,2794,2794,7869,341,0,0]B`[6270,13,2794,2794,7774,341,0,0]B`[6270,13,2794,2794,6368,341,0,0]B`[6270,13,2252,2252,7286,341,0,0]B`[6270,13,2252,2252,7550,341,0,0]B`[6270,13,2252,2252,7854,341,0,0]B`[6270,13,5293,5293,7312,341,0,0]B`[6270,13,5293,5293,8044,341,0,0]B`[6270,13,5293,5293,7317,341,0,0]B`[6270,13,5293,5293,7856,341,0,0]B`[6270,13,3046,3046,6627,341,0,0]B`[6270,13,3046,3046,7932,341,0,0]B`[6270,13,3046,3046,7871,341,0,0]B`[6270,13,3046,3046,4649,341,0,0]B`[6270,13,3046,3046,3369,341,0,0]B`[6270,13,3046,3046,6319,341,0,0]B`[6270,13,3046,3046,6523,341,0,0]B`[6270,13,3046,3046,7057,341,0,0]B`[6270,13,3046,3046,6322,341,0,0]B`[6270,13,3046,3046,4613,341,0,0]B`[6270,13,2789,2789,6028,341,0,0]B`[6270,13,2789,2789,7039,341,0,0]B`[6270,13,6304,6304,7867,341,0,0]B`[6270,13,5326,5326,6630,341,0,0]B`[6270,13,5654,5654,7292,341,0,0]B`[6270,13,5654,5654,6628,341,0,0]B`[6270,13,2902,2902,6512,341,0,0]B`[6270,13,2902,2902,7289,341,0,0]B`[6270,13,2902,2902,7290,341,0,0]B`[6270,13,2902,2902,7291,341,0,0]B`[6270,13,2902,2902,7455,341,0,0]B`[6270,13,4218,4218,6637,341,0,0]B`[6270,13,5323,5323,5377,341,0,0]B`[6270,13,5323,5323,3359,341,0,0]B`[6270,13,5323,5323,5342,341,0,0]B`[6270,13,5323,5323,4192,341,0,0]B`[6270,13,2997,2997,7442,341,0,0]B`[6270,13,3012,3012,5367,341,0,0]B`[6270,13,3498,3498,7779,341,0,0]B`[6270,13,6110,6110,7556,341,0,0]B`[6270,13,6110,6110,7440,341,0,0]B`[6270,13,6110,6110,7987,341,0,0]B`[6270,13,6110,6110,7766,341,0,0]B`[6270,13,5410,5410,6624,341,0,0]B`[6270,13,5410,5410,7040,341,0,0]B`[6270,13,5410,5410,6623,341,0,0]B`[6270,13,2235,2235,4182,341,0,0]B`[6270,13,2235,2235,4653,341,0,0]B`[6270,13,5732,5732,6045,341,0,0]B`[6270,13,3398,3398,6629,341,0,0]B`[6270,13,3398,3398,6032,341,0,0]B`[6270,13,3398,3398,6033,341,0,0]B`[6270,13,2052,2052,5337,341,0,0]B`[6270,13,2052,2052,4651,341,0,0]B`[6270,13,2644,2644,6330,341,0,0]B`[6270,13,3010,3010,5364,341,0,0]B`[6270,13,3010,3010,6048,341,0,0]B`[6270,13,3010,3010,7450,341,0,0]B`[6270,13,2841,2841,6506,341,0,0]B`[6270,13,2841,2841,6504,341,0,0]B`[6270,13,2841,2841,6328,341,0,0]B`[6270,13,2627,2627,6514,341,0,0]B`[6270,13,2627,2627,6042,341,0,0]B`[6270,13,2627,2627,5381,341,0,0]B`[6270,13,2627,2627,6519,341,0,0]B`[6270,13,2627,2627,4190,341,0,0]B`[6270,13,5683,5683,4630,341,0,0]B`[6270,13,5683,5683,4168,341,0,0]B`[6270,13,5683,5683,4628,341,0,0]B`[6270,13,2290,2290,6515,341,0,0]B`[6270,13,2290,2290,7776,341,0,0]B`[6270,13,2290,2290,7937,341,0,0]B`[6270,13,2290,2290,7991,341,0,0]B`[6270,13,6278,6278,7446,341,0,0]B`[6270,13,6278,6278,5356,341,0,0]B`[6270,13,6278,6278,3361,341,0,0]B`[6270,13,6278,6278,1007,341,0,0]B`[6270,13,6452,6452,8292,341,0,0]B`[6270,13,6452,6452,7567,341,0,0]B`[6270,13,6452,6452,6053,341,0,0]B`[6270,13,3494,3494,7059,341,0,0]B`[6270,13,3494,3494,6640,341,0,0]B`[6270,13,3494,3494,7307,341,0,0]B`[6270,13,6616,6616,4656,341,0,0]B`[6270,13,6616,6616,6375,341,0,0]B`[6270,13,3514,3514,6508,341,0,0]B`[6270,13,2793,2793,6069,341,0,0]B`[6270,13,2793,2793,7061,341,0,0]B`[6270,13,2793,2793,7445,341,0,0]B`[6270,13,2280,2280,4166,341,0,0]B`[6270,13,2280,2280,5374,341,0,0]B`[6270,13,1718,1718,7327,341,0,0]B`[6270,13,1718,1718,4650,341,0,0]B`[6270,13,2898,2898,5366,341,0,0]B`[6270,13,2898,2898,5371,341,0,0]B`[6270,13,6277,6277,2029,341,0,0]B`[6270,13,6277,6277,7060,341,0,0]B`[6270,13,6277,6277,6029,341,0,0]B`[6270,13,6277,6277,5369,341,0,0]B`[6270,13,2646,2646,7323,341,0,0]B`[6270,13,2646,2646,7310,341,0,0]B`[6270,13,5708,5708,4639,341,0,0]B`[6270,13,5708,5708,6335,341,0,0]B`[6270,13,6496,6496,6077,341,0,0]B`[6270,13,6496,6496,6079,341,0,0]B`[6270,13,3034,3034,7562,341,0,0]B`[6270,13,3034,3034,7048,341,0,0]B`[6270,13,2256,2256,4644,341,0,0]B`[6270,13,2256,2256,6062,341,0,0]B`[6270,13,2256,2256,5334,341,0,0]B`[6270,13,7334,7334,7038,341,0,0]B`[6270,13,2236,2236,6060,341,0,0]B`[6270,13,2236,2236,6373,341,0,0]B`[6270,13,2236,2236,4179,341,0,0]B`[6270,13,2236,2236,4184,341,0,0]B`[6270,13,2236,2236,5359,341,0,0]B`[6270,13,3392,3392,7034,341,0,0]B`[6270,13,3392,3392,7036,341,0,0]B`[6270,13,6431,6431,6041,341,0,0]B`[6270,13,5240,5240,4185,341,0,0]B`[6270,13,5240,5240,2358,341,0,0]B`[6270,13,5240,5240,2329,341,0,0]B`[6270,13,5755,5755,6528,341,0,0]B`[6270,13,5755,5755,6653,341,0,0]B`[6270,13,5316,5316,6049,341,0,0]B`[6270,13,5316,5316,6370,341,0,0]Am[6270,13,5242,5242,4,341,0,0]B`[6270,13,5242,5242,2361,341,0,0]B`[6270,13,5242,5242,7049,341,0,0]B`[6270,13,3015,3015,7301,341,0,0]B`[6270,13,3015,3015,4614,341,0,0]B`[6270,13,2291,2291,4196,341,0,0]B`[6270,13,2291,2291,2856,341,0,0]B`[6270,13,2291,2291,3368,341,0,0]B`[6270,13,2291,2291,4610,341,0,0]B`[6270,13,2291,2291,2336,341,0,0]B`[6270,13,2291,2291,4180,341,0,0]B`[6270,13,2291,2291,5361,341,0,0]B`[6270,13,2291,2291,5360,341,0,0]B`[6270,13,2291,2291,6073,341,0,0]B`[6270,13,2291,2291,7294,341,0,0]B`[6270,13,2291,2291,6631,341,0,0]B`[6270,13,2291,2291,5380,341,0,0]B`[6270,13,2291,2291,6371,341,0,0]B`[6270,13,2291,2291,6050,341,0,0]B`[6270,13,2291,2291,6516,341,0,0]B`[6270,13,2291,2291,2008,341,0,0]B`[6270,13,2291,2291,4174,341,0,0]B`[6270,13,2291,2291,6036,341,0,0]B`[6270,13,2291,2291,6367,341,0,0]B`[6270,13,2291,2291,7864,341,0,0]B`[6270,13,2787,2787,7053,341,0,0]B`[6270,13,2787,2787,4176,341,0,0]B`[6270,13,2787,2787,5348,341,0,0]B`[6270,13,2787,2787,6336,341,0,0]B`[6270,13,2787,2787,7931,341,0,0]B`[6270,13,2787,2787,7865,341,0,0]B`[6270,13,2787,2787,7772,341,0,0]B`[6270,13,2787,2787,6037,341,0,0]B`[6270,13,2787,2787,7045,341,0,0]B`[6270,13,2787,2787,4173,341,0,0]B`[6270,13,2787,2787,7560,341,0,0]B`[6270,13,2787,2787,7559,341,0,0]B`[6270,13,2787,2787,7564,341,0,0]B`[6270,13,2787,2787,6327,341,0,0]B`[6270,13,2787,2787,7293,341,0,0]B`[6270,13,2787,2787,7457,341,0,0]B`[6270,13,2787,2787,6657,341,0,0]B`[6270,13,2787,2787,7775,341,0,0]B`[6270,13,2787,2787,7050,341,0,0]B`[6270,13,2787,2787,7051,341,0,0]B`[6270,13,2658,2658,7044,341,0,0]B`[6270,13,7276,7276,4203,341,0,0]B`[6270,13,7276,7276,6051,341,0,0]B`[6270,13,7276,7276,4194,341,0,0]B`[6270,13,7276,7276,6369,341,0,0]B`[6270,13,7276,7276,6656,341,0,0]B`[6270,13,6601,6601,7778,341,0,0]B`[6270,13,6601,6601,5375,341,0,0]B`[6270,13,3505,3505,5385,341,0,0]B`[6270,13,3505,3505,3333,341,0,0]B`[6270,13,3505,3505,6321,341,0,0]B`[6270,13,3505,3505,7065,341,0,0]B`[6270,13,3505,3505,6377,341,0,0]B`[6270,13,3505,3505,6633,341,0,0]B`[6270,13,3045,3045,3325,341,0,0]B`[6270,13,5067,5067,5383,341,0,0]B`[6270,13,5067,5067,6645,341,0,0]B`[6270,13,5067,5067,2030,341,0,0]B`[6270,13,5067,5067,4642,341,0,0]B`[6270,13,2784,2784,6055,341,0,0]B`[6270,13,4589,4589,7571,341,0,0]B`[6270,13,2054,2054,6530,341,0,0]B`[6270,13,2054,2054,5338,341,0,0]B`[6270,13,2842,2842,6659,341,0,0]B`[6270,13,2842,2842,7939,341,0,0]An[6270,13,6278,6278,5356,0,0,0]An[6270,14,1016,1016,2877,0,0,0]An[6270,14,1732,1732,6376,0,0,0]An[6270,14,3399,3399,8290,0,0,0]An[6270,14,3399,3399,7558,0,0,0]An[6270,14,3399,3399,7769,0,0,0]An[6270,14,3046,3046,6319,0,0,0]An[6270,14,3398,3398,6032,0,0,0]An[6270,14,2627,2627,5381,0,0,0]An[6270,14,3494,3494,7059,0,0,0]An[6270,14,3015,3015,7301,0,0,0]An[6270,13,2787,2787,7053,0,0,0]An[6270,14,2787,2787,7053,0,0,0]An[6270,14,2787,2787,7564,0,0,0]An[6270,13,2658,2658,7044,0,0,0]An[6270,14,1016,1016,4648,0,0,0]An[6270,14,1016,1016,7062,0,0,0]An[6270,14,3046,3046,4649,0,0,0]An[6270,13,6277,6277,6029,0,0,0]Ah[6270,18,2627,0,0,0,0,0]Ah[6270,18,5683,0,0,0,0,0]An[6270,15,2291,2291,4174,0,0,0]An[6270,15,3010,3010,5364,0,0,0]An[6270,15,2291,2291,4196,0,0,0]Ag[6270,5,3045,0,0,0,0,0]Ag[6270,8,5067,0,0,0,0,0]Ah[6270,18,6281,0,0,0,0,0]Ah[6270,18,3505,0,0,0,0,0]An[6270,15,5755,5755,6528,0,0,0]Ag[6270,6,3505,0,0,0,0,0]7An[6270,15,5323,5323,3359,0,0,0]An[6270,15,2280,2280,4166,0,0,0]Ah[6270,18,5242,0,0,0,0,0]An[6270,15,5410,5410,6623,0,0,0]:An[6270,15,3505,3505,7065,0,0,0]An[6270,15,2794,2794,7055,0,0,0]An[6270,15,2794,2794,7303,0,0,0]An[6270,15,2235,2235,4182,0,0,0]An[6270,15,2236,2236,6060,0,0,0]An[6270,15,2644,2644,6330,0,0,0]An[6270,15,3494,3494,7307,0,0,0]Ag[6270,6,5323,0,0,0,0,0]Ah[6270,18,5655,0,0,0,0,0]Ag[6270,6,6278,0,0,0,0,0]An[6270,15,2235,2235,4653,0,0,0]Ah[6270,10,5323,0,0,0,0,0]Ag[6270,5,2291,0,0,0,0,0]Ag[6270,5,3046,0,0,0,0,0]An[6270,15,2280,2280,4166,0,0,0]0An[6270,15,7264,7264,8047,0,0,0]An[6270,14,2784,2784,6055,0,0,0]An[6270,14,3399,3399,7031,0,0,0]An[6270,14,5756,5756,7552,0,0,0]An[6270,14,5756,5756,7551,0,0,0]An[6270,14,2794,2794,6636,0,0,0]An[6270,14,2794,2794,7863,0,0,0]Ak[6270,14,7054,0,1984,0,0,0]An[6270,14,3046,3046,4613,0,0,0]An[6270,14,3012,3012,5367,0,0,0]An[6270,14,2627,2627,6519,0,0,0]An[6270,14,2236,2236,4179,0,0,0]Ak[6270,14,6058,0,1985,0,0,0]An[6270,14,3015,3015,4614,0,0,0]An[6270,14,2291,2291,4180,0,0,0]An[6270,14,2787,2787,6037,0,0,0]An[6270,14,5316,5316,6370,0,0,0]An[6270,14,6277,6277,5369,0,0,0]Ak[6270,14,7308,0,7070,0,0,0]Ag[6270,2,2793,0,0,0,0,0]An[6270,13,6278,6278,5356,0,0,0]An[6270,14,2627,2627,4190,0,0,0]Ag[6270,2,6271,0,0,0,0,0]An[6270,14,5323,5323,5342,0,0,0]1Ag[6270,2,3395,0,0,0,0,0]An[6270,13,2841,2841,6506,0,0,0]An[6270,13,2842,2842,6659,0,0,0]An[6270,14,6453,6453,7568,0,0,0]An[6270,14,2794,2794,6054,0,0,0]An[6270,14,2794,2794,6636,0,0,0]An[6270,13,2793,2793,7061,0,0,0]Ak[6270,14,5372,0,2867,0,0,0]Ak[6270,14,5372,0,2331,0,0,0]An[6270,12,2898,2898,6366,0,0,0]Ba[6270,13,2898,2898,5366,6366,0,0]An[6270,14,2898,2898,5366,0,0,0]An[6270,14,6277,6277,7060,0,0,0]Ak[6270,14,7308,0,6520,0,0,0]Ak[6270,14,7308,0,5352,0,0,0]Ba[6270,13,5708,5708,4639,6366,0,0]An[6270,14,2236,2236,4179,0,0,0]An[6270,14,5240,5240,2358,0,0,0]An[6270,14,5316,5316,6049,0,0,0]An[6270,14,2789,2789,6028,0,0,0]Ak[6270,14,6526,0,1010,0,0,0]An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0]An[6270,13,2787,2787,7053,0,0,0]An[6270,13,6452,6452,7567,0,0,0]An[6270,14,6496,6496,6079,0,0,0]An[6270,14,6431,6431,6041,0,0,0]An[6270,14,2794,2794,5378,0,0,0]Ak[6270,14,7308,0,6056,0,0,0]An[6270,13,2256,2256,6062,0,0,0]An[6270,14,2794,2794,6636,0,0,0]An[6270,14,2236,2236,4179,0,0,0]An[6270,14,2291,2291,4180,0,0,0]An[6270,14,2627,2627,4190,0,0,0]An[6270,14,5293,5293,7856,0,0,0]Ag[6270,2,6271,0,0,0,0,0]Ad[3439,3,0,0,0,0,0,0]An[6270,14,2236,2236,6373,0,0,0]An[6270,14,2291,2291,5361,0,0,0]An[6270,14,2787,2787,7560,0,0,0]Ak[6270,14,7308,0,6074,0,0,0]Ak[6270,14,7308,0,7070,0,0,0]An[6270,14,5708,5708,4639,0,0,0]An[6270,14,3046,3046,4613,0,0,0]An[6270,14,2789,2789,6028,0,0,0]An[6270,14,3015,3015,4614,0,0,0]=An[6270,14,2787,2787,6037,0,0,0]Ag[6270,7,3019,0,0,0,0,0]An[6270,14,6271,6271,7037,0,0,0]Ag[6270,2,3395,0,0,0,0,0]An[6270,13,6601,6601,7778,0,0,0]An[6270,14,6453,6453,7568,0,0,0]An[6270,14,2794,2794,6054,0,0,0]An[6270,14,2252,2252,7286,0,0,0]An[6270,14,2252,2252,7550,0,0,0]An[6270,14,2252,2252,7854,0,0,0]Ak[6270,14,7324,0,2318,0,0,0]Ak[6270,14,8038,0,6064,0,0,0]Ak[6270,14,7868,0,2320,0,0,0]Ak[6270,14,7868,0,6065,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,13,2280,2280,5374,0,0,0]Ak[6270,14,5372,0,2867,0,0,0]Ak[6270,14,5372,0,2331,0,0,0]An[6270,12,2898,2898,6366,0,0,0]Ba[6270,13,2898,2898,5366,6366,0,0]An[6270,14,2898,2898,5366,0,0,0]An[6270,14,6277,6277,7060,0,0,0]Ak[6270,14,7308,0,6520,0,0,0]Ak[6270,14,7308,0,6502,0,0,0]Ak[6270,14,7308,0,7070,0,0,0]Ba[6270,13,5708,5708,4639,6366,0,0]Ak[6270,14,7032,0,6070,0,0,0]An[6270,14,5240,5240,2358,0,0,0]An[6270,14,5316,5316,6049,0,0,0]>An[6270,13,2256,2256,6062,0,0,0]An[6270,14,3399,3399,7558,0,0,0]An[6270,14,3399,3399,7769,0,0,0]An[6270,14,3398,3398,6032,0,0,0]0Ak[6270,14,6329,0,3354,0,0,0]An[6270,14,2787,2787,7457,0,0,0]Ag[6270,7,1723,0,0,0,0,0]0An[6270,13,6110,6110,7556,0,0,0]An[6270,14,6110,6110,7556,0,0,0]An[6270,14,2793,2793,6069,0,0,0]Ag[6270,2,7499,0,0,0,0,0]An[6270,14,2902,2902,7455,0,0,0]An[6270,14,6277,6277,7060,0,0,0]>Ag[6270,2,3395,0,0,0,0,0]Ak[6270,14,7308,0,6650,0,0,0]An[6270,13,6452,6452,7567,0,0,0]An[6270,12,6277,6277,5355,0,0,0]Ba[6270,13,6431,6431,6041,5355,0,0]Ba[6270,13,5240,5240,4185,5355,0,0]Ag[6270,2,6271,0,0,0,0,0]Ag[6270,2,2794,0,0,0,0,0]432Ag[6270,7,6278,0,0,0,0,0]An[6270,13,6496,6496,6077,0,0,0]An[6270,15,5732,5732,6045,0,0,0]An[6270,15,7276,7276,6051,0,0,0]Aj[6270,4,5290,7276,0,0,0,0]Ag[6270,6,7276,0,0,0,0,0]3An[6270,14,5323,5323,5342,0,0,0]An[6270,14,7276,7276,6656,0,0,0]An[6270,14,2054,2054,6530,0,0,0]An[6270,14,2291,2291,5360,0,0,0]An[6270,14,2787,2787,7559,0,0,0]An[6270,14,2787,2787,7293,0,0,0]An[6270,14,2787,2787,7457,0,0,0]An[6270,14,7264,7264,6649,0,0,0]An[6270,14,2627,2627,5381,0,0,0]An[6270,14,2290,2290,6515,0,0,0]An[6270,14,3399,3399,7295,0,0,0]An[6270,13,2054,2054,6530,0,0,0]An[6270,13,6277,6277,6029,0,0,0]Ag[6270,2,3395,0,0,0,0,0]An[6270,14,3399,3399,7558,0,0,0]An[6270,14,3399,3399,7769,0,0,0]An[6270,14,3398,3398,6032,0,0,0]Ak[6270,14,6329,0,4160,0,0,0]Ak[6270,14,5357,0,2011,0,0,0]2An[6270,13,3045,3045,3325,0,0,0]An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0]Ag[6270,7,5326,0,0,0,0,0]An[6270,14,6110,6110,7556,0,0,0]An[6270,13,5316,5316,6049,0,0,0]0An[6270,14,5316,5316,6370,0,0,0]Ag[6270,2,2793,0,0,0,0,0]An[6270,13,2793,2793,7061,0,0,0]Ag[6270,7,5323,0,0,0,0,0]=<;:An[6270,13,3514,3514,6508,0,0,0]Ag[6270,7,5732,0,0,0,0,0]An[6270,14,2291,2291,4180,0,0,0]An[6270,14,2787,2787,6037,0,0,0]An[6270,14,2787,2787,7775,0,0,0]21An[6270,14,2787,2787,7050,0,0,0]An[6270,14,2794,2794,6054,0,0,0]=An[6270,13,1016,1016,5335,0,0,0]Ag[6270,7,6496,0,0,0,0,0]An[6270,13,2658,2658,7044,0,0,0]An[6270,14,7276,7276,6656,0,0,0]Ag[6270,2,6271,0,0,0,0,0]Ag[6270,2,3395,0,0,0,0,0]Ag[6270,7,2794,0,0,0,0,0]Ak[6270,14,7302,0,3348,0,0,0]Ak[6270,14,7054,0,3347,0,0,0]Ak[6270,14,6058,0,3349,0,0,0]An[6270,14,2789,2789,6028,0,0,0]An[6270,14,3015,3015,4614,0,0,0]An[6270,14,2291,2291,4180,0,0,0]An[6270,14,2787,2787,6037,0,0,0]An[6270,13,6616,6616,6375,0,0,0]An[6270,12,4218,4218,6035,0,0,0]An[6270,14,6452,6452,7567,0,0,0]An[6270,14,2793,2793,7061,0,0,0]An[6270,13,2291,2291,6371,0,0,0]Ag[6270,7,2841,0,0,0,0,0]An[6270,14,3046,3046,4613,0,0,0]An[6270,14,3012,3012,5367,0,0,0]:9An[6270,13,3015,3015,7301,0,0,0]0An[6270,14,3399,3399,7769,0,0,0]An[6270,14,3398,3398,6032,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,13,6110,6110,7766,0,0,0]An[6270,13,3514,3514,6508,0,0,0]An[6270,14,2256,2256,6062,0,0,0]An[6270,14,5756,5756,6654,0,0,0]An[6270,14,2052,2052,5337,0,0,0]=An[6270,14,6601,6601,7778,0,0,0]An[6270,14,5756,5756,7552,0,0,0]Ba[6270,13,5410,5410,6623,7068,0,0]Ba[6270,13,2841,2841,6328,7068,0,0]Ba[6270,13,2898,2898,5371,7068,0,0]Ba[6270,13,2291,2291,4196,7068,0,0]Ba[6270,13,2842,2842,7939,7068,0,0]An[6270,13,2052,2052,4651,0,0,0]An[6270,14,3046,3046,6523,0,0,0]An[6270,14,5410,5410,6624,0,0,0]An[6270,13,3505,3505,7065,0,0,0]Ag[6270,2,2793,0,0,0,0,0]?An[6270,15,5683,5683,4168,0,0,0]An[6270,15,2291,2291,4196,0,0,0]An[6270,15,2646,2646,7310,0,0,0]Ag[6270,5,2841,0,0,0,0,0]Aj[6270,4,3398,2841,0,0,0,0]1Aj[6270,4,7499,3514,0,0,0,0]Ag[6270,5,3514,0,0,0,0,0]Ah[6270,18,5242,0,0,0,0,0]An[6270,15,7334,7334,7038,0,0,0]Ag[6270,5,2902,0,0,0,0,0]Aj[6270,4,3438,2627,0,0,0,0]Ag[6270,5,2627,0,0,0,0,0]Ag[6270,5,2290,0,0,0,0,0]Ag[6270,5,2291,0,0,0,0,0]An[6270,15,2280,2280,4166,0,0,0]43Aj[6270,4,2793,5755,0,0,0,0]Ag[6270,6,5755,0,0,0,0,0]Aj[6270,4,4589,2054,0,0,0,0]Ag[6270,5,2054,0,0,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,13,2898,2898,6366,0,0,0]Ba[6270,13,2898,2898,5366,6366,0,0]An[6270,14,2898,2898,5366,0,0,0]An[6270,13,6277,6277,5355,0,0,0]An[6270,14,6277,6277,6029,0,0,0]Ba[6270,13,5708,5708,4639,6366,0,0]An[6270,14,5708,5708,4639,0,0,0]Ba[6270,13,6431,6431,6041,5355,0,0]An[6270,13,5683,5683,4628,0,0,0]Ag[6270,7,5067,0,0,0,0,0]Ag[6270,2,5290,0,0,0,0,0]An[6270,14,2787,2787,7564,0,0,0]Ag[6270,7,3399,0,0,0,0,0]An[6270,13,2787,2787,7053,0,0,0]An[6270,13,2658,2658,7044,0,0,0]2An[6270,14,5410,5410,6624,0,0,0]An[6270,14,3399,3399,7769,0,0,0]An[6270,14,3398,3398,6032,0,0,0]An[6270,14,7276,7276,6369,0,0,0]An[6270,14,2291,2291,6631,0,0,0]An[6270,13,2054,2054,6530,0,0,0]07Ag[6270,7,5708,0,0,0,0,0]Ag[6270,7,5193,0,0,0,0,0]Ag[6270,7,7013,0,0,0,0,0]Ag[6270,7,3019,0,0,0,0,0]Ag[6270,7,7334,0,0,0,0,0]An[6270,14,5708,5708,4639,0,0,0]An[6270,14,2902,2902,7291,0,0,0]An[6270,14,2291,2291,6516,0,0,0]An[6270,14,6496,6496,6077,0,0,0]Ag[6270,7,5323,0,0,0,0,0]An[6270,13,6110,6110,7556,0,0,0]An[6270,14,3399,3399,7768,0,0,0]An[6270,13,3398,3398,6629,0,0,0]An[6270,14,3398,3398,6629,0,0,0]An[6270,14,2054,2054,5338,0,0,0]An[6270,14,1016,1016,4648,0,0,0]Ag[6270,7,3012,0,0,0,0,0]Ag[6270,7,3399,0,0,0,0,0]Ag[6270,7,2252,0,0,0,0,0]Ak[6270,14,3358,0,6363,0,0,0]Ak[6270,14,3358,0,6641,0,0,0]An[6270,14,2627,2627,4190,0,0,0]An[6270,14,2290,2290,7776,0,0,0]An[6270,14,2290,2290,7937,0,0,0]An[6270,14,2290,2290,7991,0,0,0]Ak[6270,14,7305,0,2034,0,0,0]Ak[6270,14,7305,0,3355,0,0,0]Ak[6270,14,7305,0,2880,0,0,0]An[6270,14,3399,3399,7769,0,0,0]An[6270,14,3398,3398,6032,0,0,0]Ag[6270,7,5067,0,0,0,0,0]An[6270,12,5067,5067,6626,0,0,0]Ba[6270,13,5067,5067,6645,6626,0,0]An[6270,13,3494,3494,7059,0,0,0]Ag[6270,7,2902,0,0,0,0,0]Ag[6270,7,5655,0,0,0,0,0]An[6270,13,2787,2787,7053,0,0,0]An[6270,13,6110,6110,7766,0,0,0]3An[6270,14,2627,2627,6519,0,0,0]An[6270,14,2787,2787,6037,0,0,0]Ag[6270,7,2054,0,0,0,0,0]An[6270,13,2054,2054,6530,0,0,0]Ag[6270,2,6271,0,0,0,0,0]An[6270,13,7264,7264,6649,0,0,0]An[6270,13,6110,6110,7556,0,0,0]An[6270,13,2793,2793,7061,0,0,0]01An[6270,14,7276,7276,6369,0,0,0]Ag[6270,7,6271,0,0,0,0,0]Ag[6270,7,3399,0,0,0,0,0]An[6270,14,2794,2794,7304,0,0,0]=An[6270,14,6277,6277,7060,0,0,0]An[6270,14,6277,6277,5369,0,0,0]An[6270,14,5240,5240,4185,0,0,0]Ag[6270,2,7499,0,0,0,0,0]An[6270,13,3494,3494,7059,0,0,0]Ak[6270,14,5242,5242,4,0,0,0]Ag[6270,7,5326,0,0,0,0,0];An[6270,13,2256,2256,6062,0,0,0]An[6270,13,1016,1016,5335,0,0,0]Ag[6270,6,5410,0,0,0,0,0]Ag[6270,5,5410,0,0,0,0,0]Aj[6270,4,3045,5067,0,0,0,0]Ah[6270,10,5067,0,0,0,0,0]Ag[6270,5,3046,0,0,0,0,0]Ag[6270,5,5654,0,0,0,0,0]Ag[6270,5,3398,0,0,0,0,0]Ag[6270,5,5326,0,0,0,0,0]Ag[6270,5,2291,0,0,0,0,0]An[6270,15,1718,1718,7327,0,0,0]Ag[6270,5,3505,0,0,0,0,0]1Ah[6270,10,5290,0,0,0,0,0]Ag[6270,5,2794,0,0,0,0,0]Ag[6270,5,4218,0,0,0,0,0]1Aj[6270,4,7499,3494,0,0,0,0]Ag[6270,5,3494,0,0,0,0,0]An[6270,15,5323,5323,3359,0,0,0]Ah[6270,18,5683,0,0,0,0,0]?Ag[6270,5,5067,0,0,0,0,0]Ah[6270,18,7013,0,0,0,0,0]Aj[6270,4,1016,7264,0,0,0,0]Ag[6270,5,7264,0,0,0,0,0]An[6270,15,2646,2646,7310,0,0,0]Aj[6270,4,2793,5755,0,0,0,0]Ag[6270,5,5755,0,0,0,0,0]Ag[6270,5,5756,0,0,0,0,0]Aj[6270,4,5290,7276,0,0,0,0]Ag[6270,5,7276,0,0,0,0,0]Ag[6270,5,2787,0,0,0,0,0]Aj[6270,4,4589,2842,0,0,0,0]Ag[6270,5,2842,0,0,0,0,0]An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0]An[6270,13,3010,3010,6048,0,0,0]An[6270,13,3010,3010,7450,0,0,0]Ag[6270,7,1017,0,0,0,0,0]An[6270,14,3399,3399,7768,0,0,0]An[6270,14,5756,5756,6654,0,0,0]Ag[6270,7,5708,0,0,0,0,0]Ag[6270,7,3399,0,0,0,0,0]Ba[6270,13,6271,6271,7037,6324,0,1]Ba[6270,13,1016,1016,4648,6324,0,1]Ba[6270,13,1016,1016,1013,6324,0,1]Ba[6270,13,1016,1016,4612,6324,0,1]Ba[6270,13,1016,1016,2877,6324,0,1]Ba[6270,13,7264,7264,8047,6324,0,1]Ba[6270,13,1732,1732,6376,6324,0,1]Ba[6270,13,3399,3399,7935,6324,0,1]Ba[6270,13,3399,3399,6331,6324,0,1]Ba[6270,13,3399,3399,7295,6324,0,1]Ba[6270,13,3399,3399,4609,6324,0,1]Ba[6270,13,3399,3399,6076,6324,0,1]Ba[6270,13,3399,3399,7031,6324,0,1]Ba[6270,13,3399,3399,7768,6324,0,1]Ba[6270,13,5756,5756,6654,6324,0,1]Ba[6270,13,5756,5756,7552,6324,0,1]Ba[6270,13,5756,5756,7551,6324,0,1]Ba[6270,13,2794,2794,6636,6324,0,1]Ba[6270,13,5293,5293,7317,6324,0,1]Ba[6270,13,5293,5293,7856,6324,0,1]Ba[6270,13,3046,3046,4649,6324,0,1]Ba[6270,13,3046,3046,3369,6324,0,1]Ba[6270,13,3046,3046,6319,6324,0,1]Ba[6270,13,3046,3046,6523,6324,0,1]Ba[6270,13,3046,3046,7057,6324,0,1]Ba[6270,13,3046,3046,6322,6324,0,1]Ba[6270,13,3046,3046,4613,6324,0,1]Ba[6270,13,2789,2789,6028,6324,0,1]Ba[6270,13,2789,2789,7039,6324,0,1]Ba[6270,13,6304,6304,7867,6324,0,1]Ba[6270,13,5654,5654,7292,6324,0,1]Ba[6270,13,5654,5654,6628,6324,0,1]Ba[6270,13,2902,2902,7289,6324,0,1]Ba[6270,13,2902,2902,7290,6324,0,1]Ba[6270,13,2902,2902,7291,6324,0,1]Ba[6270,13,2902,2902,7455,6324,0,1]Ba[6270,13,4218,4218,6637,6324,0,1]Ba[6270,13,5323,5323,3359,6324,0,1]Ba[6270,13,5323,5323,5342,6324,0,1]Ba[6270,13,5323,5323,4192,6324,0,1]Ba[6270,13,2997,2997,7442,6324,0,1]Ba[6270,13,6110,6110,7440,6324,0,1]Ba[6270,13,6110,6110,7987,6324,0,1]Ba[6270,13,6110,6110,7766,6324,0,1]Ba[6270,13,2235,2235,4182,6324,0,1]Ba[6270,13,2235,2235,4653,6324,0,1]Ba[6270,13,5732,5732,6045,6324,0,1]Ba[6270,13,3398,3398,6032,6324,0,1]Ba[6270,13,3398,3398,6033,6324,0,1]Ba[6270,13,2052,2052,5337,6324,0,1]Ba[6270,13,2052,2052,4651,6324,0,1]Ba[6270,13,2644,2644,6330,6324,0,1]Ba[6270,13,3010,3010,5364,6324,0,1]Ba[6270,13,3010,3010,6048,6324,0,1]Ba[6270,13,3010,3010,7450,6324,0,1]Ba[6270,13,2627,2627,6514,6324,0,1]Ba[6270,13,2627,2627,6042,6324,0,1]Ba[6270,13,2627,2627,5381,6324,0,1]Ba[6270,13,2627,2627,6519,6324,0,1]Ba[6270,13,2627,2627,4190,6324,0,1]Ba[6270,13,5683,5683,4630,6324,0,1]Ba[6270,13,5683,5683,4168,6324,0,1]Ba[6270,13,5683,5683,4628,6324,0,1]Ba[6270,13,2290,2290,6515,6324,0,1]Ba[6270,13,6452,6452,6053,6324,0,1]Ba[6270,13,3494,3494,7059,6324,0,1]Ba[6270,13,3494,3494,6640,6324,0,1]Ba[6270,13,3494,3494,7307,6324,0,1]Ba[6270,13,3514,3514,6508,6324,0,1]Ba[6270,13,2793,2793,7445,6324,0,1]Ba[6270,13,2280,2280,4166,6324,0,1]Ba[6270,13,2280,2280,5374,6324,0,1]Ba[6270,13,1718,1718,7327,6324,0,1]Ba[6270,13,1718,1718,4650,6324,0,1]Ba[6270,13,2898,2898,5371,6324,0,1]Ba[6270,13,6277,6277,2029,6324,0,1]Ba[6270,13,6277,6277,7060,6324,0,1]Ba[6270,13,6277,6277,6029,6324,0,1]Ba[6270,13,6277,6277,5369,6324,0,1]Ba[6270,13,2646,2646,7310,6324,0,1]Ba[6270,13,3034,3034,7048,6324,0,1]Ba[6270,13,2256,2256,4644,6324,0,1]Ba[6270,13,2256,2256,6062,6324,0,1]Ba[6270,13,2256,2256,5334,6324,0,1]Ba[6270,13,7334,7334,7038,6324,0,1]Ba[6270,13,2236,2236,6060,6324,0,1]Ba[6270,13,2236,2236,6373,6324,0,1]Ba[6270,13,2236,2236,4179,6324,0,1]Ba[6270,13,2236,2236,4184,6324,0,1]Ba[6270,13,2236,2236,5359,6324,0,1]Ba[6270,13,3392,3392,7034,6324,0,1]Ba[6270,13,3392,3392,7036,6324,0,1]Ba[6270,13,5240,5240,2329,6324,0,1]Ba[6270,13,5755,5755,6528,6324,0,1]Ba[6270,13,5755,5755,6653,6324,0,1]Ba[6270,13,5316,5316,6049,6324,0,1]Ba[6270,13,2291,2291,4196,6324,0,1]Ba[6270,13,2291,2291,2856,6324,0,1]Ba[6270,13,2291,2291,3368,6324,0,1]Ba[6270,13,2291,2291,4610,6324,0,1]Ba[6270,13,2291,2291,2336,6324,0,1]Ba[6270,13,2291,2291,4180,6324,0,1]Ba[6270,13,2291,2291,5361,6324,0,1]Ba[6270,13,2291,2291,5360,6324,0,1]Ba[6270,13,2291,2291,6073,6324,0,1]Ba[6270,13,2291,2291,7294,6324,0,1]Ba[6270,13,2291,2291,6631,6324,0,1]Ba[6270,13,2291,2291,5380,6324,0,1]Ba[6270,13,2291,2291,6371,6324,0,1]Ba[6270,13,2291,2291,6050,6324,0,1]Ba[6270,13,2291,2291,6516,6324,0,1]Ba[6270,13,2291,2291,2008,6324,0,1]Ba[6270,13,2291,2291,4174,6324,0,1]Ba[6270,13,2291,2291,6036,6324,0,1]Ba[6270,13,2291,2291,6367,6324,0,1]Ba[6270,13,2291,2291,7864,6324,0,1]Ba[6270,13,2787,2787,4176,6324,0,1]Ba[6270,13,2787,2787,5348,6324,0,1]Ba[6270,13,2787,2787,7045,6324,0,1]Ba[6270,13,2787,2787,7560,6324,0,1]Ba[6270,13,2787,2787,7559,6324,0,1]Ba[6270,13,7276,7276,4203,6324,0,1]Ba[6270,13,7276,7276,6051,6324,0,1]Ba[6270,13,7276,7276,4194,6324,0,1]Ba[6270,13,7276,7276,6369,6324,0,1]Ba[6270,13,7276,7276,6656,6324,0,1]Ba[6270,13,6601,6601,5375,6324,0,1]Ba[6270,13,3505,3505,5385,6324,0,1]Ba[6270,13,3505,3505,3333,6324,0,1]Ba[6270,13,3505,3505,6321,6324,0,1]Ba[6270,13,3505,3505,7065,6324,0,1]Ba[6270,13,3505,3505,6377,6324,0,1]Ba[6270,13,3505,3505,6633,6324,0,1]Ba[6270,13,5067,5067,2030,6324,0,1]Ba[6270,13,5067,5067,4642,6324,0,1]Ba[6270,13,2784,2784,6055,6324,0,1]Ba[6270,13,4589,4589,7571,6324,0,1]Ba[6270,13,2054,2054,5338,6324,0,1]An[6270,14,3046,3046,6322,0,0,0]An[6270,14,2291,2291,2856,0,0,0]An[6270,14,3399,3399,6331,0,0,0]Ag[6270,7,3399,0,0,0,0,0]An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0]Ak[6270,14,7308,0,7780,0,0,0]Ak[6270,14,7032,0,7781,0,0,0]An[6270,14,1016,1016,5335,0,0,0]76543Ak[6270,14,5242,5242,4,0,0,0]88An[6270,14,3398,3398,6032,0,0,0]An[6270,14,3505,3505,6321,0,0,0]An[6270,13,2052,2052,4651,0,0,0]=An[6270,13,7276,7276,6656,0,0,0]An[6270,13,2627,2627,6514,0,0,0]An[6270,13,2054,2054,6530,0,0,0]1An[6270,13,5683,5683,4628,0,0,0]An[6270,13,2787,2787,7053,0,0,0]An[6270,13,2658,2658,7044,0,0,0]Ag[6270,7,5326,0,0,0,0,0]An[6270,13,2787,2787,6037,0,0,0]An[6270,13,2793,2793,7061,0,0,0]0An[6270,13,6452,6452,7567,0,0,0]An[6270,14,6452,6452,7567,0,0,0]0An[6270,14,3494,3494,7059,0,0,0]3An[6270,14,2793,2793,7061,0,0,0]An[6270,14,6601,6601,7778,0,0,0]Ag[6270,7,3046,0,0,0,0,0]An[6270,14,2291,2291,6050,0,0,0]An[6270,14,2291,2291,6516,0,0,0]An[6270,14,7276,7276,6656,0,0,0]Ba[6270,13,1016,1016,4648,4187,0,0]Ba[6270,13,1016,1016,1013,4187,0,0]Ba[6270,13,1016,1016,4612,4187,0,0]Ba[6270,13,1016,1016,2877,4187,0,0]Ba[6270,13,1016,1016,7062,4187,0,0]Ba[6270,13,1016,1016,8144,4187,0,0]Ba[6270,13,1016,1016,6038,4187,0,0]Ba[6270,13,1732,1732,6376,4187,0,0]Ba[6270,13,3399,3399,7935,4187,0,0]Ba[6270,13,3399,3399,6331,4187,0,0]Ba[6270,13,3399,3399,7295,4187,0,0]Ba[6270,13,3399,3399,8290,4187,0,0]Ba[6270,13,3399,3399,7558,4187,0,0]Ba[6270,13,3399,3399,7769,4187,0,0]Ba[6270,13,3399,3399,6076,4187,0,0]Ba[6270,13,3399,3399,7031,4187,0,0]Ba[6270,13,3399,3399,7768,4187,0,0]Ba[6270,13,5756,5756,6654,4187,0,0]Ba[6270,13,5756,5756,7552,4187,0,0]Ba[6270,13,5756,5756,7551,4187,0,0]Ba[6270,13,6453,6453,7568,4187,0,0]Ba[6270,13,2794,2794,6054,4187,0,0]Ba[6270,13,2794,2794,6636,4187,0,0]Ba[6270,13,2794,2794,5378,4187,0,0]Ba[6270,13,2794,2794,6078,4187,0,0]Ba[6270,13,2794,2794,6638,4187,0,0]Ba[6270,13,2794,2794,7055,4187,0,0]Ba[6270,13,2794,2794,7056,4187,0,0]Ba[6270,13,2794,2794,7863,4187,0,0]Ba[6270,13,2794,2794,7304,4187,0,0]Ba[6270,13,2794,2794,7303,4187,0,0]Ba[6270,13,2794,2794,7325,4187,0,0]Ba[6270,13,2794,2794,8039,4187,0,0]Ba[6270,13,2794,2794,7869,4187,0,0]Ba[6270,13,2794,2794,7774,4187,0,0]Ba[6270,13,2794,2794,6368,4187,0,0]Ba[6270,13,2252,2252,7286,4187,0,0]Ba[6270,13,2252,2252,7550,4187,0,0]Ba[6270,13,2252,2252,7854,4187,0,0]Ba[6270,13,5293,5293,7317,4187,0,0]Ba[6270,13,5293,5293,7856,4187,0,0]Ba[6270,13,3046,3046,4649,4187,0,0]Ba[6270,13,3046,3046,3369,4187,0,0]Ba[6270,13,3046,3046,6319,4187,0,0]Ba[6270,13,3046,3046,6523,4187,0,0]Ba[6270,13,3046,3046,7057,4187,0,0]Ba[6270,13,3046,3046,6322,4187,0,0]Ba[6270,13,3046,3046,4613,4187,0,0]Ba[6270,13,2789,2789,6028,4187,0,0]Ba[6270,13,2789,2789,7039,4187,0,0]Ba[6270,13,6304,6304,7867,4187,0,0]Ba[6270,13,5326,5326,6630,4187,0,0]Ba[6270,13,5654,5654,7292,4187,0,0]Ba[6270,13,5654,5654,6628,4187,0,0]Ba[6270,13,2902,2902,7289,4187,0,0]Ba[6270,13,2902,2902,7290,4187,0,0]Ba[6270,13,2902,2902,7291,4187,0,0]Ba[6270,13,2902,2902,7455,4187,0,0]Ba[6270,13,5323,5323,5342,4187,0,0]Ba[6270,13,2997,2997,7442,4187,0,0]Ba[6270,13,3012,3012,5367,4187,0,0]Ba[6270,13,6110,6110,7440,4187,0,0]Ba[6270,13,6110,6110,7987,4187,0,0]Ba[6270,13,2235,2235,4182,4187,0,0]Ba[6270,13,2235,2235,4653,4187,0,0]Ba[6270,13,3398,3398,6032,4187,0,0]Ba[6270,13,2052,2052,5337,4187,0,0]Ba[6270,13,3010,3010,5364,4187,0,0]Ba[6270,13,3010,3010,6048,4187,0,0]Ba[6270,13,2627,2627,6042,4187,0,0]Ba[6270,13,2627,2627,5381,4187,0,0]Ba[6270,13,2627,2627,6519,4187,0,0]Ba[6270,13,2627,2627,4190,4187,0,0]Ba[6270,13,6452,6452,6053,4187,0,0]Ba[6270,13,2280,2280,4166,4187,0,0]Ba[6270,13,2280,2280,5374,4187,0,0]Ba[6270,13,2646,2646,7310,4187,0,0]Ba[6270,13,3034,3034,7048,4187,0,0]Ba[6270,13,2256,2256,4644,4187,0,0]Ba[6270,13,2256,2256,6062,4187,0,0]Ba[6270,13,7334,7334,7038,4187,0,0]Ba[6270,13,2236,2236,6060,4187,0,0]Ba[6270,13,2236,2236,6373,4187,0,0]Ba[6270,13,2236,2236,4179,4187,0,0]Ba[6270,13,2236,2236,4184,4187,0,0]Ba[6270,13,2236,2236,5359,4187,0,0]Ba[6270,13,3392,3392,7034,4187,0,0]Ba[6270,13,3392,3392,7036,4187,0,0]Ba[6270,13,5755,5755,6528,4187,0,0]Ba[6270,13,5755,5755,6653,4187,0,0]Ba[6270,13,2291,2291,4196,4187,0,0]Ba[6270,13,2291,2291,2856,4187,0,0]Ba[6270,13,2291,2291,3368,4187,0,0]Ba[6270,13,2291,2291,4610,4187,0,0]Ba[6270,13,2291,2291,2336,4187,0,0]Ba[6270,13,2291,2291,4180,4187,0,0]Ba[6270,13,2291,2291,5361,4187,0,0]Ba[6270,13,2291,2291,5360,4187,0,0]Ba[6270,13,2291,2291,6073,4187,0,0]Ba[6270,13,2291,2291,7294,4187,0,0]Ba[6270,13,2291,2291,6631,4187,0,0]Ba[6270,13,2291,2291,5380,4187,0,0]Ba[6270,13,2291,2291,6371,4187,0,0]Ba[6270,13,2291,2291,6050,4187,0,0]Ba[6270,13,2291,2291,6516,4187,0,0]Ba[6270,13,2291,2291,2008,4187,0,0]Ba[6270,13,2291,2291,4174,4187,0,0]Ba[6270,13,2291,2291,6036,4187,0,0]Ba[6270,13,2291,2291,6367,4187,0,0]Ba[6270,13,2291,2291,7864,4187,0,0]Ba[6270,13,2787,2787,4176,4187,0,0]Ba[6270,13,7276,7276,4203,4187,0,0]Ba[6270,13,7276,7276,6051,4187,0,0]Ba[6270,13,7276,7276,4194,4187,0,0]Ba[6270,13,7276,7276,6369,4187,0,0]Ba[6270,13,7276,7276,6656,4187,0,0]Ba[6270,13,3505,3505,5385,4187,0,0]Ba[6270,13,3505,3505,3333,4187,0,0]Ba[6270,13,3505,3505,6321,4187,0,0]Ba[6270,13,3505,3505,7065,4187,0,0]Ba[6270,13,3505,3505,6377,4187,0,0]Ba[6270,13,3505,3505,6633,4187,0,0]Ba[6270,13,4589,4589,7571,4187,0,0]An[6270,13,2898,2898,5371,0,0,0]An[6270,14,3046,3046,4613,0,0,0]An[6270,14,3015,3015,4614,0,0,0]An[6270,14,2291,2291,4180,0,0,0]An[6270,14,2787,2787,6037,0,0,0]An[6270,14,5240,5240,2358,0,0,0]Ag[6270,7,5323,0,0,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,13,2627,2627,6514,0,0,0]1An[6270,14,2290,2290,7776,0,0,0]An[6270,14,2290,2290,7937,0,0,0]An[6270,14,2290,2290,7991,0,0,0]An[6270,14,2291,2291,6050,0,0,0]An[6270,14,2291,2291,6516,0,0,0]An[6270,14,2787,2787,7775,0,0,0]An[6270,14,7276,7276,6656,0,0,0]Ag[6270,7,2794,0,0,0,0,0]Ag[6270,7,3454,0,0,0,0,0]00An[6270,13,2787,2787,7053,0,0,0]An[6270,14,3046,3046,4613,0,0,0]An[6270,14,3015,3015,4614,0,0,0]An[6270,14,2291,2291,4180,0,0,0]An[6270,14,2787,2787,6037,0,0,0]An[6270,13,5316,5316,6049,0,0,0]Ag[6270,7,5323,0,0,0,0,0]An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0]Ag[6270,7,6543,0,0,0,0,0]Ag[6270,7,3399,0,0,0,0,0]An[6270,13,5410,5410,6624,0,0,0]Ag[6270,2,5290,0,0,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,13,2898,2898,6366,0,0,0]11An[6270,14,5756,5756,7552,0,0,0]An[6270,13,6110,6110,7556,0,0,0]Ag[6270,7,1732,0,0,0,0,0]44An[6270,14,2787,2787,7050,0,0,0]=<;:An[6270,13,3494,3494,7059,0,0,0]Ag[6270,7,5316,0,0,0,0,0]Ag[6270,5,3399,0,0,0,0,0]Aj[6270,4,2793,3392,0,0,0,0]Ag[6270,6,3392,0,0,0,0,0]1Ag[6270,5,3392,0,0,0,0,0]Ag[6270,5,6271,0,0,0,0,0]Ag[6270,6,7334,0,0,0,0,0]Ag[6270,5,2789,0,0,0,0,0]Ag[6270,6,5410,0,0,0,0,0]Ah[6270,18,5242,0,0,0,0,0]Aj[6270,4,5290,2658,0,0,0,0]Ag[6270,5,2658,0,0,0,0,0]Ag[6270,5,2787,0,0,0,0,0]Aj[6270,4,2793,3034,0,0,0,0]Ag[6270,6,3034,0,0,0,0,0]Ag[6270,5,5242,0,0,0,0,0]33Aj[6270,4,5290,2787,0,0,0,0]4Ag[6270,6,2794,0,0,0,0,0]Ag[6270,5,2794,0,0,0,0,0]Ag[6270,5,3046,0,0,0,0,0]Aj[6270,4,7499,3494,0,0,0,0]Ag[6270,5,3494,0,0,0,0,0]Ag[6270,5,6277,0,0,0,0,0]Ag[6270,5,2793,0,0,0,0,0]Ag[6270,5,1016,0,0,0,0,0]Ah[6270,18,5655,0,0,0,0,0]Ag[6270,6,3505,0,0,0,0,0]Ah[6270,18,6452,0,0,0,0,0]Ah[6270,18,5407,0,0,0,0,0]Ah[6270,18,2902,0,0,0,0,0]An[6270,15,2646,2646,7310,0,0,0]An[6270,14,6616,6616,6375,0,0,0]An[6270,14,3398,3398,6629,0,0,0]Ag[6270,7,3399,0,0,0,0,0]Ag[6270,7,1017,0,0,0,0,0]0Ak[6270,14,5242,5242,4,0,0,0]Ag[6270,7,6271,0,0,0,0,0]An[6270,14,3398,3398,6032,0,0,0]Ak[6270,14,6329,0,3354,0,0,0]An[6270,14,2841,2841,6504,0,0,0]An[6270,14,3046,3046,4613,0,0,0]An[6270,14,3015,3015,4614,0,0,0]An[6270,14,2291,2291,4180,0,0,0]An[6270,14,2787,2787,6037,0,0,0]An[6270,14,6110,6110,7440,0,0,0]An[6270,14,2793,2793,7061,0,0,0]An[6270,14,5410,5410,6624,0,0,0]Ag[6270,7,3505,0,0,0,0,0]An[6270,13,5410,5410,6624,0,0,0]Ag[6270,7,5323,0,0,0,0,0]An[6270,13,5316,5316,6049,0,0,0]An[6270,14,2794,2794,6638,0,0,0]Ag[6270,7,3399,0,0,0,0,0]Ba[6270,13,6271,6271,7037,4199,0,0]Ba[6270,13,1016,1016,4648,4199,0,0]Ba[6270,13,1016,1016,1013,4199,0,0]Ba[6270,13,1016,1016,4612,4199,0,0]Ba[6270,13,1016,1016,2877,4199,0,0]Ba[6270,13,7264,7264,8047,4199,0,0]Ba[6270,13,1732,1732,6376,4199,0,0]Ba[6270,13,3399,3399,7935,4199,0,0]Ba[6270,13,3399,3399,6331,4199,0,0]Ba[6270,13,3399,3399,7295,4199,0,0]Ba[6270,13,3399,3399,6076,4199,0,0]Ba[6270,13,3399,3399,7031,4199,0,0]Ba[6270,13,3399,3399,7768,4199,0,0]Ba[6270,13,5756,5756,6654,4199,0,0]Ba[6270,13,5756,5756,7552,4199,0,0]Ba[6270,13,5756,5756,7551,4199,0,0]Ba[6270,13,6453,6453,7568,4199,0,0]Ba[6270,13,2794,2794,6054,4199,0,0]Ba[6270,13,2794,2794,6636,4199,0,0]Ba[6270,13,2794,2794,5378,4199,0,0]Ba[6270,13,2794,2794,6078,4199,0,0]Ba[6270,13,2794,2794,7325,4199,0,0]Ba[6270,13,2794,2794,8039,4199,0,0]Ba[6270,13,2794,2794,7869,4199,0,0]Ba[6270,13,2794,2794,7774,4199,0,0]Ba[6270,13,2794,2794,6368,4199,0,0]Ba[6270,13,5293,5293,7317,4199,0,0]Ba[6270,13,5293,5293,7856,4199,0,0]Ba[6270,13,3046,3046,4649,4199,0,0]Ba[6270,13,3046,3046,3369,4199,0,0]Ba[6270,13,3046,3046,6319,4199,0,0]Ba[6270,13,3046,3046,6523,4199,0,0]Ba[6270,13,3046,3046,7057,4199,0,0]Ba[6270,13,3046,3046,6322,4199,0,0]Ba[6270,13,3046,3046,4613,4199,0,0]Ba[6270,13,2789,2789,6028,4199,0,0]Ba[6270,13,2789,2789,7039,4199,0,0]Ba[6270,13,6304,6304,7867,4199,0,0]Ba[6270,13,5654,5654,7292,4199,0,0]Ba[6270,13,5654,5654,6628,4199,0,0]Ba[6270,13,2902,2902,7289,4199,0,0]Ba[6270,13,2902,2902,7290,4199,0,0]Ba[6270,13,2902,2902,7291,4199,0,0]Ba[6270,13,2902,2902,7455,4199,0,0]Ba[6270,13,5323,5323,5342,4199,0,0]Ba[6270,13,2997,2997,7442,4199,0,0]Ba[6270,13,3012,3012,5367,4199,0,0]Ba[6270,13,6110,6110,7440,4199,0,0]Ba[6270,13,6110,6110,7987,4199,0,0]Ba[6270,13,2235,2235,4182,4199,0,0]Ba[6270,13,2235,2235,4653,4199,0,0]Ba[6270,13,3398,3398,6032,4199,0,0]Ba[6270,13,2052,2052,5337,4199,0,0]Ba[6270,13,2644,2644,6330,4199,0,0]Ba[6270,13,3010,3010,5364,4199,0,0]Ba[6270,13,3010,3010,6048,4199,0,0]Ba[6270,13,2627,2627,6042,4199,0,0]Ba[6270,13,2627,2627,5381,4199,0,0]Ba[6270,13,2627,2627,6519,4199,0,0]Ba[6270,13,2627,2627,4190,4199,0,0]Ba[6270,13,2290,2290,7776,4199,0,0]Ba[6270,13,2290,2290,7937,4199,0,0]Ba[6270,13,2290,2290,7991,4199,0,0]Ba[6270,13,6452,6452,6053,4199,0,0]Ba[6270,13,2280,2280,4166,4199,0,0]Ba[6270,13,2280,2280,5374,4199,0,0]Ba[6270,13,2646,2646,7310,4199,0,0]Ba[6270,13,3034,3034,7048,4199,0,0]Ba[6270,13,2256,2256,4644,4199,0,0]Ba[6270,13,2256,2256,6062,4199,0,0]Ba[6270,13,7334,7334,7038,4199,0,0]Ba[6270,13,2236,2236,6060,4199,0,0]Ba[6270,13,2236,2236,6373,4199,0,0]Ba[6270,13,2236,2236,4179,4199,0,0]Ba[6270,13,2236,2236,4184,4199,0,0]Ba[6270,13,2236,2236,5359,4199,0,0]Ba[6270,13,3392,3392,7034,4199,0,0]Ba[6270,13,3392,3392,7036,4199,0,0]Ba[6270,13,5755,5755,6528,4199,0,0]Ba[6270,13,5755,5755,6653,4199,0,0]Ba[6270,13,2291,2291,4196,4199,0,0]Ba[6270,13,2291,2291,2856,4199,0,0]Ba[6270,13,2291,2291,3368,4199,0,0]Ba[6270,13,2291,2291,4610,4199,0,0]Ba[6270,13,2291,2291,2336,4199,0,0]Ba[6270,13,2291,2291,4180,4199,0,0]Ba[6270,13,2291,2291,5361,4199,0,0]Ba[6270,13,2291,2291,5360,4199,0,0]Ba[6270,13,2291,2291,6073,4199,0,0]Ba[6270,13,2291,2291,7294,4199,0,0]Ba[6270,13,2291,2291,6631,4199,0,0]Ba[6270,13,2291,2291,5380,4199,0,0]Ba[6270,13,2291,2291,6371,4199,0,0]Ba[6270,13,2291,2291,6050,4199,0,0]Ba[6270,13,2291,2291,6516,4199,0,0]Ba[6270,13,2291,2291,2008,4199,0,0]Ba[6270,13,2291,2291,4174,4199,0,0]Ba[6270,13,2291,2291,6036,4199,0,0]Ba[6270,13,2291,2291,6367,4199,0,0]Ba[6270,13,2291,2291,7864,4199,0,0]Ba[6270,13,2787,2787,4176,4199,0,0]Ba[6270,13,2787,2787,7564,4199,0,0]Ba[6270,13,2787,2787,6327,4199,0,0]Ba[6270,13,2787,2787,7293,4199,0,0]Ba[6270,13,2787,2787,7457,4199,0,0]Ba[6270,13,2787,2787,6657,4199,0,0]Ba[6270,13,7276,7276,4203,4199,0,0]Ba[6270,13,7276,7276,6051,4199,0,0]Ba[6270,13,7276,7276,4194,4199,0,0]Ba[6270,13,7276,7276,6369,4199,0,0]Ba[6270,13,7276,7276,6656,4199,0,0]Ba[6270,13,3505,3505,5385,4199,0,0]Ba[6270,13,3505,3505,3333,4199,0,0]Ba[6270,13,3505,3505,6321,4199,0,0]Ba[6270,13,3505,3505,7065,4199,0,0]Ba[6270,13,3505,3505,6377,4199,0,0]Ba[6270,13,3505,3505,6633,4199,0,0]Ba[6270,13,2784,2784,6055,4199,0,0]Ba[6270,13,4589,4589,7571,4199,0,0]An[6270,14,6110,6110,7766,0,0,0]Ag[6270,7,5407,0,0,0,0,0]An[6270,14,3505,3505,6321,0,0,0]Ak[6270,14,5242,5242,4,0,0,0]An[6270,13,2658,2658,7044,0,0,0]An[6270,13,5410,5410,6624,0,0,0]An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0]Ag[6270,7,5242,0,0,0,0,0]Ag[6270,7,2794,0,0,0,0,0]54325432An[6270,13,2793,2793,7061,0,0,0]Ag[6270,7,5316,0,0,0,0,0]8Ak[6270,14,5357,0,2342,0,0,0]Ak[6270,14,5357,0,2011,0,0,0]An[6270,14,2291,2291,3368,0,0,0]An[6270,14,2787,2787,7045,0,0,0]An[6270,14,3514,3514,6508,0,0,0]An[6270,14,2902,2902,7289,0,0,0]An[6270,14,2902,2902,7290,0,0,0]An[6270,14,6496,6496,6077,0,0,0]Ag[6270,7,3399,0,0,0,0,0]An[6270,13,6110,6110,7556,0,0,0]An[6270,13,3015,3015,7301,0,0,0]=An[6270,14,3505,3505,6321,0,0,0]An[6270,14,6453,6453,7568,0,0,0]An[6270,14,2627,2627,5381,0,0,0]An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0]Ag[6270,7,2794,0,0,0,0,0]0An[6270,14,2291,2291,6367,0,0,0]An[6270,14,2787,2787,7772,0,0,0]An[6270,13,5316,5316,6049,0,0,0]Ag[6270,7,5655,0,0,0,0,0]An[6270,13,2793,2793,7061,0,0,0]Ag[6270,7,5407,0,0,0,0,0]Ag[6270,2,1016,0,0,0,0,0]2Ag[6270,7,6271,0,0,0,0,0]An[6270,13,6601,6601,7778,0,0,0]An[6270,13,6452,6452,7567,0,0,0]An[6270,13,5708,5708,4639,0,0,0]An[6270,13,6110,6110,7556,0,0,0]An[6270,14,2787,2787,7564,0,0,0]An[6270,13,3034,3034,7562,0,0,0]Ag[6270,7,3046,0,0,0,0,0]00Ag[6270,2,5290,0,0,0,0,0]Ag[6270,7,1732,0,0,0,0,0]0;An[6270,14,5654,5654,6628,0,0,0]An[6270,14,4589,4589,7571,0,0,0]5Ba[6270,13,2291,2291,4180,6333,0,0]Ba[6270,13,2291,2291,6073,6333,0,0]An[6270,12,5193,5193,6333,0,0,0]Ag[6270,5,2252,0,0,0,0,0]An[6270,15,1718,1718,7327,0,0,0]Ah[6270,18,2902,0,0,0,0,0]Ag[6270,5,2902,0,0,0,0,0]00Ag[6270,5,5654,0,0,0,0,0]Ag[6270,5,2787,0,0,0,0,0]Ag[6270,5,2291,0,0,0,0,0]Ag[6270,5,3399,0,0,0,0,0]6Ag[6270,9,2902,0,0,0,0,0]Ah[6270,18,5323,0,0,0,0,0]Ah[6270,10,2291,0,0,0,0,0]Aj[6270,4,5290,3015,0,0,0,0]Ag[6270,5,3015,0,0,0,0,0]Ag[6270,6,2794,0,0,0,0,0]Ag[6270,5,2794,0,0,0,0,0]Aj[6270,4,7499,3494,0,0,0,0]Ag[6270,6,3494,0,0,0,0,0]Aj[6270,4,2793,2646,0,0,0,0]Ag[6270,6,2646,0,0,0,0,0]Aj[6270,4,3395,5293,0,0,0,0]Ag[6270,5,5293,0,0,0,0,0]Ah[6270,18,5293,0,0,0,0,0]Ah[6270,18,5655,0,0,0,0,0]Ah[6270,18,1718,0,0,0,0,0]Ag[6270,9,2787,0,0,0,0,0]4An[6270,15,2646,2646,7310,0,0,0]0;8Ag[6270,5,2646,0,0,0,0,0]=Aj[6270,4,2793,1718,0,0,0,0]Ag[6270,6,1718,0,0,0,0,0]An[6270,15,2644,2644,6330,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,14,6304,6304,7867,0,0,0]1An[6270,14,2793,2793,7061,0,0,0]Ag[6270,7,7334,0,0,0,0,0]Ag[6270,2,2793,0,0,0,0,0]Ag[6270,7,7013,0,0,0,0,0]An[6270,13,5683,5683,4628,0,0,0]An[6270,13,1016,1016,5335,0,0,0]Ak[6270,14,7308,0,7780,0,0,0]An[6270,14,3392,3392,7036,0,0,0]Ak[6270,14,7032,0,7781,0,0,0]Ag[6270,7,2794,0,0,0,0,0]An[6270,13,5755,5755,6653,0,0,0]An[6270,14,2291,2291,5360,0,0,0]An[6270,14,2787,2787,7559,0,0,0]Ag[6270,7,5708,0,0,0,0,0]An[6270,14,3505,3505,6321,0,0,0]Ag[6270,7,5193,0,0,0,0,0]An[6270,14,3505,3505,6633,0,0,0]1An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0];32103210An[6270,13,3034,3034,7048,0,0,0]An[6270,12,5067,5067,6626,0,0,0]Ba[6270,13,5067,5067,6645,6626,0,0]Ag[6270,7,2802,0,0,0,0,0]An[6270,13,2627,2627,6514,0,0,0]An[6270,14,6496,6496,6077,0,0,0]An[6270,14,2794,2794,6054,0,0,0]An[6270,14,2794,2794,6638,0,0,0]An[6270,14,2236,2236,4179,0,0,0]An[6270,14,2291,2291,6050,0,0,0]An[6270,13,7276,7276,6051,0,0,0]An[6270,14,7276,7276,6656,0,0,0]An[6270,13,2793,2793,7061,0,0,0]Ag[6270,7,3012,0,0,0,0,0]1Ag[6270,7,5407,0,0,0,0,0]Ag[6270,7,6453,0,0,0,0,0]3An[6270,14,2793,2793,7061,0,0,0]An[6270,14,5708,5708,4639,0,0,0]An[6270,14,6452,6452,7567,0,0,0]Ag[6270,7,2794,0,0,0,0,0]000An[6270,14,1016,1016,1013,0,0,0]An[6270,14,1016,1016,6038,0,0,0]An[6270,14,2794,2794,6636,0,0,0]?An[6270,14,3046,3046,4613,0,0,0]An[6270,14,2789,2789,6028,0,0,0]An[6270,14,3012,3012,5367,0,0,0]An[6270,14,2627,2627,6519,0,0,0]An[6270,14,2236,2236,4179,0,0,0]An[6270,14,3015,3015,4614,0,0,0]An[6270,14,2291,2291,2336,0,0,0]An[6270,14,2291,2291,4180,0,0,0]An[6270,13,2658,2658,7044,0,0,0]Ag[6270,7,5655,0,0,0,0,0]An[6270,13,3398,3398,6629,0,0,0]Ag[6270,7,5708,0,0,0,0,0]Ak[6270,14,7308,0,7318,0,0,0]Ak[6270,14,7308,0,6325,0,0,0]An[6270,14,5755,5755,6653,0,0,0]An[6270,13,2793,2793,7061,0,0,0]0Ag[6270,7,5293,0,0,0,0,0]0Ag[6270,7,3399,0,0,0,0,0]Ag[6270,7,2794,0,0,0,0,0]:Ag[6270,7,3498,0,0,0,0,0]An[6270,13,6452,6452,7567,0,0,0]5An[6270,13,6601,6601,7778,0,0,0]46Ag[6270,7,7013,0,0,0,0,0]Ag[6270,7,3046,0,0,0,0,0]000Ag[6270,7,2787,0,0,0,0,0]An[6270,14,3505,3505,6377,0,0,0]An[6270,14,2787,2787,6327,0,0,0]Ag[6270,7,6543,0,0,0,0,0]Ag[6270,7,2902,0,0,0,0,0]An[6270,14,5756,5756,7551,0,0,0]An[6270,14,3514,3514,6508,0,0,0]:An[6270,14,2794,2794,6054,0,0,0]Ag[6270,7,5407,0,0,0,0,0]An[6270,13,2787,2787,7053,0,0,0]Ag[6270,5,1017,0,0,0,0,0]Ah[6270,18,2902,0,0,0,0,0]0Ag[6270,5,6110,0,0,0,0,0]Ah[6270,18,5683,0,0,0,0,0]Ag[6270,5,2997,0,0,0,0,0]An[6270,19,3015,3015,7301,0,0,0]Ag[6270,5,2793,0,0,0,0,0]Ag[6270,5,6278,0,0,0,0,0]Ah[6270,18,5708,0,0,0,0,0]0Ah[6270,18,6543,0,0,0,0,0]Ag[6270,5,3010,0,0,0,0,0]Ah[6270,10,2793,0,0,0,0,0]Aj[6270,4,2793,2646,0,0,0,0]Ah[6270,10,2646,0,0,0,0,0]Ag[6270,5,2794,0,0,0,0,0]Ag[6270,5,2902,0,0,0,0,0]Ag[6270,8,2646,0,0,0,0,0]Ag[6270,5,2787,0,0,0,0,0]Ag[6270,9,2787,0,0,0,0,0]An[6270,13,6616,6616,6375,0,0,0]An[6270,14,2052,2052,4651,0,0,0]An[6270,14,2794,2794,6368,0,0,0]An[6270,13,6110,6110,7766,0,0,0]An[6270,14,2787,2787,7564,0,0,0]An[6270,13,2658,2658,7044,0,0,0]An[6270,13,6601,6601,7778,0,0,0]Ag[6270,7,5407,0,0,0,0,0]An[6270,13,2052,2052,4651,0,0,0]An[6270,13,3398,3398,6629,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,13,6110,6110,7556,0,0,0]An[6270,14,3505,3505,6321,0,0,0]An[6270,14,2291,2291,7294,0,0,0]Ag[6270,7,5654,0,0,0,0,0]Ag[6270,7,6277,0,0,0,0,0]55An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0]An[6270,14,2291,2291,6631,0,0,0]An[6270,14,2291,2291,6371,0,0,0]Ag[6270,7,5655,0,0,0,0,0]Aj[6270,4,2640,6407,0,0,0,0]Ag[6270,7,6407,0,0,0,0,0]An[6270,14,6496,6496,6077,0,0,0]0An[6270,14,2787,2787,6327,0,0,0]An[6270,14,2787,2787,7457,0,0,0]Ag[6270,7,6543,0,0,0,0,0]2<;:9An[6270,13,2787,2787,7053,0,0,0]An[6270,13,1016,1016,5335,0,0,0]An[6270,14,2291,2291,7294,0,0,0]Ag[6270,2,6271,0,0,0,0,0]Ag[6270,2,3395,0,0,0,0,0]Ag[6270,7,3031,0,0,0,0,0]Ag[6270,7,2794,0,0,0,0,0]0Ag[6270,7,3454,0,0,0,0,0]00An[6270,13,2793,2793,7061,0,0,0]An[6270,14,2793,2793,7061,0,0,0]An[6270,14,5708,5708,4639,0,0,0]:Ag[6270,7,3399,0,0,0,0,0]Ag[6270,7,5655,0,0,0,0,0]4Ag[6270,7,5293,0,0,0,0,0]An[6270,13,5683,5683,4628,0,0,0]Ag[6270,7,3046,0,0,0,0,0]Ag[6270,7,3015,0,0,0,0,0]An[6270,13,3015,3015,7301,0,0,0]An[6270,13,2658,2658,7044,0,0,0]An[6270,12,6277,6277,5355,0,0,0]Ba[6270,13,6431,6431,6041,5355,0,0]Ba[6270,13,5240,5240,4185,5355,0,0]Ag[6270,7,5290,0,0,0,0,0]Ag[6270,7,6543,0,0,0,0,0]An[6270,13,2627,2627,6514,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,13,2898,2898,6366,0,0,0]Ba[6270,13,2898,2898,5366,6366,0,0]An[6270,13,6277,6277,5355,0,0,0]Ba[6270,13,5708,5708,4639,6366,0,0]9An[6270,13,3398,3398,6629,0,0,0]54An[6270,13,2052,2052,4651,0,0,0]6Ag[6270,7,2789,0,0,0,0,0]An[6270,14,6277,6277,6029,0,0,0]An[6270,14,2627,2627,5381,0,0,0]An[6270,13,5410,5410,6624,0,0,0]An[6270,14,2291,2291,6073,0,0,0]An[6270,14,2787,2787,6336,0,0,0]An[6270,14,2787,2787,7865,0,0,0]An[6270,13,6452,6452,7567,0,0,0]An[6270,13,3034,3034,7562,0,0,0]An[6270,14,2794,2794,7774,0,0,0]Ag[6270,7,6543,0,0,0,0,0]Ag[6270,5,2252,0,0,0,0,0]Ag[6270,5,5756,0,0,0,0,0]0An[6270,15,1718,1718,7327,0,0,0]Ag[6270,5,2794,0,0,0,0,0]Ah[6270,18,2902,0,0,0,0,0]Ag[6270,5,6110,0,0,0,0,0]Ah[6270,18,5683,0,0,0,0,0]Ag[6270,5,3399,0,0,0,0,0]Ag[6270,5,2787,0,0,0,0,0]0Aj[6270,4,2793,3034,0,0,0,0]Ag[6270,5,3034,0,0,0,0,0]32An[6270,15,2646,2646,7310,0,0,0]Ag[6270,5,6452,0,0,0,0,0]Ag[6270,5,6453,0,0,0,0,0]Ah[6270,10,6452,0,0,0,0,0]Ah[6270,18,6543,0,0,0,0,0]Ag[6270,5,4589,0,0,0,0,0]An[6270,14,2627,2627,5381,0,0,0]Ag[6270,7,1017,0,0,0,0,0]0An[6270,14,3015,3015,4614,0,0,0]An[6270,14,2291,2291,4180,0,0,0]An[6270,14,2787,2787,6037,0,0,0]An[6270,14,6616,6616,4656,0,0,0]Ag[6270,7,3399,0,0,0,0,0]Ag[6270,7,2902,0,0,0,0,0]An[6270,13,2793,2793,7061,0,0,0]0An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0]6An[6270,14,6601,6601,7778,0,0,0]5Ak[6270,14,5242,5242,4,0,0,0]An[6270,13,5410,5410,6624,0,0,0]Ag[6270,7,5708,0,0,0,0,0]Ba[6270,13,6271,6271,7037,4199,0,0]Ba[6270,13,1016,1016,4648,4199,0,0]Ba[6270,13,1016,1016,1013,4199,0,0]Ba[6270,13,1016,1016,4612,4199,0,0]Ba[6270,13,1016,1016,2877,4199,0,0]Ba[6270,13,7264,7264,8047,4199,0,0]Ba[6270,13,1732,1732,6376,4199,0,0]Ba[6270,13,3399,3399,7935,4199,0,0]Ba[6270,13,3399,3399,6331,4199,0,0]Ba[6270,13,3399,3399,7295,4199,0,0]Ba[6270,13,3399,3399,6076,4199,0,0]Ba[6270,13,3399,3399,7031,4199,0,0]Ba[6270,13,3399,3399,7768,4199,0,0]Ba[6270,13,5756,5756,6654,4199,0,0]Ba[6270,13,5756,5756,7552,4199,0,0]Ba[6270,13,5756,5756,7551,4199,0,0]Ba[6270,13,6453,6453,7568,4199,0,0]Ba[6270,13,2794,2794,6054,4199,0,0]Ba[6270,13,2794,2794,6636,4199,0,0]Ba[6270,13,2794,2794,5378,4199,0,0]Ba[6270,13,2794,2794,6078,4199,0,0]Ba[6270,13,2794,2794,7325,4199,0,0]Ba[6270,13,2794,2794,8039,4199,0,0]Ba[6270,13,2794,2794,7869,4199,0,0]Ba[6270,13,2794,2794,7774,4199,0,0]Ba[6270,13,2794,2794,6368,4199,0,0]Ba[6270,13,5293,5293,7317,4199,0,0]Ba[6270,13,5293,5293,7856,4199,0,0]Ba[6270,13,3046,3046,4649,4199,0,0]Ba[6270,13,3046,3046,3369,4199,0,0]Ba[6270,13,3046,3046,6319,4199,0,0]Ba[6270,13,3046,3046,6523,4199,0,0]Ba[6270,13,3046,3046,7057,4199,0,0]Ba[6270,13,3046,3046,6322,4199,0,0]Ba[6270,13,3046,3046,4613,4199,0,0]Ba[6270,13,2789,2789,6028,4199,0,0]Ba[6270,13,2789,2789,7039,4199,0,0]Ba[6270,13,6304,6304,7867,4199,0,0]Ba[6270,13,5654,5654,7292,4199,0,0]Ba[6270,13,5654,5654,6628,4199,0,0]Ba[6270,13,2902,2902,7289,4199,0,0]Ba[6270,13,2902,2902,7290,4199,0,0]Ba[6270,13,2902,2902,7291,4199,0,0]Ba[6270,13,2902,2902,7455,4199,0,0]Ba[6270,13,5323,5323,5342,4199,0,0]Ba[6270,13,2997,2997,7442,4199,0,0]Ba[6270,13,3012,3012,5367,4199,0,0]Ba[6270,13,6110,6110,7440,4199,0,0]Ba[6270,13,6110,6110,7987,4199,0,0]Ba[6270,13,2235,2235,4182,4199,0,0]Ba[6270,13,2235,2235,4653,4199,0,0]Ba[6270,13,3398,3398,6032,4199,0,0]Ba[6270,13,2052,2052,5337,4199,0,0]Ba[6270,13,2644,2644,6330,4199,0,0]Ba[6270,13,3010,3010,5364,4199,0,0]Ba[6270,13,3010,3010,6048,4199,0,0]Ba[6270,13,2627,2627,6042,4199,0,0]Ba[6270,13,2627,2627,5381,4199,0,0]Ba[6270,13,2627,2627,6519,4199,0,0]Ba[6270,13,2627,2627,4190,4199,0,0]Ba[6270,13,2290,2290,7776,4199,0,0]Ba[6270,13,2290,2290,7937,4199,0,0]Ba[6270,13,2290,2290,7991,4199,0,0]Ba[6270,13,6452,6452,6053,4199,0,0]Ba[6270,13,2280,2280,4166,4199,0,0]Ba[6270,13,2280,2280,5374,4199,0,0]Ba[6270,13,2646,2646,7310,4199,0,0]Ba[6270,13,3034,3034,7048,4199,0,0]Ba[6270,13,2256,2256,4644,4199,0,0]Ba[6270,13,2256,2256,6062,4199,0,0]Ba[6270,13,7334,7334,7038,4199,0,0]Ba[6270,13,2236,2236,6060,4199,0,0]Ba[6270,13,2236,2236,6373,4199,0,0]Ba[6270,13,2236,2236,4179,4199,0,0]Ba[6270,13,2236,2236,4184,4199,0,0]Ba[6270,13,2236,2236,5359,4199,0,0]Ba[6270,13,3392,3392,7034,4199,0,0]Ba[6270,13,3392,3392,7036,4199,0,0]Ba[6270,13,5755,5755,6528,4199,0,0]Ba[6270,13,5755,5755,6653,4199,0,0]Ba[6270,13,2291,2291,4196,4199,0,0]Ba[6270,13,2291,2291,2856,4199,0,0]Ba[6270,13,2291,2291,3368,4199,0,0]Ba[6270,13,2291,2291,4610,4199,0,0]Ba[6270,13,2291,2291,2336,4199,0,0]Ba[6270,13,2291,2291,4180,4199,0,0]Ba[6270,13,2291,2291,5361,4199,0,0]Ba[6270,13,2291,2291,5360,4199,0,0]Ba[6270,13,2291,2291,6073,4199,0,0]Ba[6270,13,2291,2291,7294,4199,0,0]Ba[6270,13,2291,2291,6631,4199,0,0]Ba[6270,13,2291,2291,5380,4199,0,0]Ba[6270,13,2291,2291,6371,4199,0,0]Ba[6270,13,2291,2291,6050,4199,0,0]Ba[6270,13,2291,2291,6516,4199,0,0]Ba[6270,13,2291,2291,2008,4199,0,0]Ba[6270,13,2291,2291,4174,4199,0,0]Ba[6270,13,2291,2291,6036,4199,0,0]Ba[6270,13,2291,2291,6367,4199,0,0]Ba[6270,13,2291,2291,7864,4199,0,0]Ba[6270,13,2787,2787,4176,4199,0,0]Ba[6270,13,2787,2787,7564,4199,0,0]Ba[6270,13,2787,2787,6327,4199,0,0]Ba[6270,13,2787,2787,7293,4199,0,0]Ba[6270,13,2787,2787,7457,4199,0,0]Ba[6270,13,2787,2787,6657,4199,0,0]Ba[6270,13,7276,7276,4203,4199,0,0]Ba[6270,13,7276,7276,6051,4199,0,0]Ba[6270,13,7276,7276,4194,4199,0,0]Ba[6270,13,7276,7276,6369,4199,0,0]Ba[6270,13,7276,7276,6656,4199,0,0]Ba[6270,13,3505,3505,5385,4199,0,0]Ba[6270,13,3505,3505,3333,4199,0,0]Ba[6270,13,3505,3505,6321,4199,0,0]Ba[6270,13,3505,3505,7065,4199,0,0]Ba[6270,13,3505,3505,6377,4199,0,0]Ba[6270,13,3505,3505,6633,4199,0,0]Ba[6270,13,2784,2784,6055,4199,0,0]Ba[6270,13,4589,4589,7571,4199,0,0]An[6270,13,2842,2842,6659,0,0,0]Ag[6270,7,6271,0,0,0,0,0]Ag[6270,7,5654,0,0,0,0,0]An[6270,13,2841,2841,6506,0,0,0]An[6270,13,2291,2291,5380,0,0,0]Ag[6270,7,5406,0,0,0,0,0]An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0]An[6270,13,2793,2793,7061,0,0,0]Ag[6270,7,5316,0,0,0,0,0]An[6270,14,6110,6110,7440,0,0,0]Ag[6270,7,2802,0,0,0,0,0]An[6270,13,2627,2627,6514,0,0,0]An[6270,13,3034,3034,7562,0,0,0]An[6270,14,3046,3046,4613,0,0,0]An[6270,14,3015,3015,4614,0,0,0]An[6270,14,2291,2291,4180,0,0,0]An[6270,14,2787,2787,6037,0,0,0]An[6270,14,2291,2291,3368,0,0,0]Ag[6270,7,2252,0,0,0,0,0]Ag[6270,7,2794,0,0,0,0,0]Ag[6270,7,2828,0,0,0,0,0]An[6270,13,3015,3015,7301,0,0,0]An[6270,13,2658,2658,7044,0,0,0]Ag[6270,7,3399,0,0,0,0,0]Aj[6270,4,2640,5732,0,0,0,0]Ag[6270,7,5732,0,0,0,0,0]Ag[6270,7,5655,0,0,0,0,0]Ag[6270,7,2802,0,0,0,0,0]0Ag[6270,7,5293,0,0,0,0,0]Ag[6270,7,1718,0,0,0,0,0]An[6270,13,2627,2627,6514,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,13,5242,5242,7049,0,0,0]An[6270,13,3034,3034,7562,0,0,0]0Ag[6270,7,3046,0,0,0,0,0]000Ag[6270,7,2789,0,0,0,0,0]11Ag[6270,7,1732,0,0,0,0,0]0An[6270,14,2052,2052,5337,0,0,0]Ag[6270,7,5326,0,0,0,0,0]0An[6270,13,3398,3398,6629,0,0,0]Ah[6270,18,2997,0,0,0,0,0]Ah[6270,18,5410,0,0,0,0,0]Ag[6270,5,6110,0,0,0,0,0]An[6270,19,3015,3015,7301,0,0,0]Ag[6270,5,3399,0,0,0,0,0]0Ah[6270,18,6543,0,0,0,0,0]Ah[6270,18,5242,0,0,0,0,0]Ag[6270,5,2787,0,0,0,0,0]7Ag[6270,5,2794,0,0,0,0,0]1Ag[6270,5,2290,0,0,0,0,0]Ah[6270,18,1718,0,0,0,0,0]Ag[6270,5,6601,0,0,0,0,0]Ag[6270,5,3498,0,0,0,0,0]An[6270,15,2646,2646,7310,0,0,0]An[6270,15,3392,3392,7034,0,0,0]69An[6270,14,6304,6304,7867,0,0,0]Ag[6270,7,5293,0,0,0,0,0]An[6270,14,1016,1016,5335,0,0,0]Ag[6270,7,2787,0,0,0,0,0]Ag[6270,7,3399,0,0,0,0,0]Ag[6270,7,5654,0,0,0,0,0]An[6270,14,2787,2787,6327,0,0,0]An[6270,13,6452,6452,7567,0,0,0]An[6270,13,3398,3398,6629,0,0,0]An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0]An[6270,13,5410,5410,6624,0,0,0]An[6270,13,3015,3015,7301,0,0,0]An[6270,13,2658,2658,7044,0,0,0]065436543An[6270,13,2787,2787,7053,0,0,0]101An[6270,13,3034,3034,7048,0,0,0]Ag[6270,7,2997,0,0,0,0,0]0>3Ag[6270,7,3012,0,0,0,0,0]Ag[6270,7,5655,0,0,0,0,0]Ag[6270,7,7013,0,0,0,0,0]An[6270,14,2627,2627,5381,0,0,0]Ag[6270,7,7500,0,0,0,0,0]An[6270,14,2291,2291,3368,0,0,0]Ag[6270,7,2252,0,0,0,0,0]An[6270,13,2052,2052,4651,0,0,0]An[6270,14,2052,2052,5337,0,0,0]An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0]Ag[6270,7,5293,0,0,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,13,2054,2054,6530,0,0,0]An[6270,13,3034,3034,7562,0,0,0]An[6270,13,3015,3015,7301,0,0,0]An[6270,13,2627,2627,6514,0,0,0]0Ag[6270,7,3046,0,0,0,0,0]0An[6270,14,2291,2291,5360,0,0,0]An[6270,14,2787,2787,7559,0,0,0]An[6270,14,2787,2787,7050,0,0,0]210Ag[6270,7,1732,0,0,0,0,0]Ag[6270,7,5323,0,0,0,0,0]Ag[6270,7,5326,0,0,0,0,0]An[6270,14,2787,2787,6327,0,0,0]An[6270,14,5756,5756,7551,0,0,0]Ag[6270,5,2252,0,0,0,0,0]Ah[6270,18,6452,0,0,0,0,0]Ag[6270,5,5293,0,0,0,0,0]Ah[6270,18,5242,0,0,0,0,0]0Ah[6270,18,2902,0,0,0,0,0]Ah[6270,18,7013,0,0,0,0,0]0Ag[6270,5,2794,0,0,0,0,0]Ag[6270,5,2291,0,0,0,0,0]Ag[6270,5,2787,0,0,0,0,0]Ah[6270,18,3506,0,0,0,0,0]Ag[6270,5,6304,0,0,0,0,0]Ag[6270,6,2794,0,0,0,0,0]Ah[6270,18,6407,0,0,0,0,0]Ag[6270,5,3046,0,0,0,0,0]An[6270,14,6277,6277,6029,0,0,0]An[6270,14,2787,2787,7564,0,0,0]An[6270,14,2793,2793,7061,0,0,0]Ag[6270,7,2794,0,0,0,0,0]Ag[6270,7,3399,0,0,0,0,0]An[6270,14,6601,6601,7778,0,0,0]Ag[6270,7,6271,0,0,0,0,0]An[6270,13,5410,5410,6624,0,0,0]Ag[6270,7,5654,0,0,0,0,0]An[6270,13,6452,6452,7567,0,0,0]An[6270,13,2793,2793,7061,0,0,0]Ag[6270,7,7013,0,0,0,0,0]7An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0]Ag[6270,7,5756,0,0,0,0,0]Ag[6270,7,2802,0,0,0,0,0]An[6270,13,2627,2627,6514,0,0,0];An[6270,14,2787,2787,7564,0,0,0]An[6270,14,2787,2787,6327,0,0,0]Ag[6270,7,3046,0,0,0,0,0];An[6270,14,2291,2291,3368,0,0,0]An[6270,14,6601,6601,7778,0,0,0]Ag[6270,7,7500,0,0,0,0,0]Ag[6270,7,3506,0,0,0,0,0]Ag[6270,7,2828,0,0,0,0,0]Ag[6270,7,2794,0,0,0,0,0]Ag[6270,7,3454,0,0,0,0,0]An[6270,13,2052,2052,4651,0,0,0]An[6270,13,2793,2793,7061,0,0,0]>>>:Ah[6270,18,6543,0,0,0,0,0]723Ag[6270,7,6271,0,0,0,0,0]2Ag[6270,7,2828,0,0,0,0,0]Ag[6270,7,5316,0,0,0,0,0]1Ag[6270,7,5293,0,0,0,0,0]00An[6270,13,3015,3015,7301,0,0,0]Ag[6270,7,3399,0,0,0,0,0]99Aj[6270,4,2793,1718,0,0,0,0]Ag[6270,7,1718,0,0,0,0,0]An[6270,13,2052,2052,4651,0,0,0]Ag[6270,7,6453,0,0,0,0,0];Ag[6270,7,2902,0,0,0,0,0]?Ag[6270,7,3046,0,0,0,0,0]2Ag[6270,7,6543,0,0,0,0,0]Ag[6270,7,3506,0,0,0,0,0]:::Ag[6270,7,2793,0,0,0,0,0]32Ah[6270,18,5316,0,0,0,0,0]Ag[6270,7,2787,0,0,0,0,0]77======5Ag[6270,7,2997,0,0,0,0,0]8Ag[6270,7,2828,0,0,0,0,0]??87An[6270,13,2793,2793,7061,0,0,0]Ag[6270,7,5293,0,0,0,0,0]Ag[6270,7,6452,0,0,0,0,0]211An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Cf[6270,13,2658,2658,7044,0,0,0,\"impl-HybridRepository\"]Dm[6270,13,2658,2658,7044,6635,0,0,\"impl-MediaRepository-for-HybridRepository\"]66Ag[6270,7,6304,0,0,0,0,0]?Ag[6270,7,3399,0,0,0,0,0]Ag[6270,7,6271,0,0,0,0,0]92An[6270,13,2787,2787,7053,0,0,0]::") \ No newline at end of file diff --git a/api/search.index/entry/a17aeb340c.js b/api/search.index/entry/a17aeb340c.js new file mode 100644 index 00000000..44d63259 --- /dev/null +++ b/api/search.index/entry/a17aeb340c.js @@ -0,0 +1 @@ +rd_("Ag[6270,5,5242,0,0,0,0,0]An[6270,15,5410,5410,6623,0,0,0]An[6270,15,5067,5067,3327,0,0,0]An[6270,15,2291,2291,5380,0,0,0]Ba[6270,13,3399,3399,4609,4189,0,0]Ba[6270,13,4218,4218,6637,4189,0,0]Ba[6270,13,5323,5323,3359,4189,0,0]Ba[6270,13,5323,5323,5342,4189,0,0]Ba[6270,13,2235,2235,4182,4189,0,0]Ba[6270,13,2235,2235,4653,4189,0,0]Ba[6270,13,5732,5732,6045,4189,0,0]Ba[6270,13,3010,3010,5364,4189,0,0]Ba[6270,13,3010,3010,6048,4189,0,0]Ba[6270,13,5683,5683,4168,4189,0,0]Ba[6270,13,6278,6278,3361,4189,0,0]Ba[6270,13,6278,6278,1007,4189,0,0]Ba[6270,13,6452,6452,6053,4189,0,0]Ba[6270,13,2280,2280,4166,4189,0,0]Ba[6270,13,1718,1718,7327,4189,0,0]Ba[6270,13,1718,1718,4650,4189,0,0]Ba[6270,13,6277,6277,2029,4189,0,0]Ba[6270,13,6277,6277,6029,4189,0,0]Ba[6270,13,3034,3034,7048,4189,0,0]Ba[6270,13,2256,2256,4644,4189,0,0]Ba[6270,13,2256,2256,5334,4189,0,0]Ba[6270,13,7334,7334,7038,4189,0,0]Ba[6270,13,2236,2236,6060,4189,0,0]Ba[6270,13,2236,2236,6373,4189,0,0]Ba[6270,13,2236,2236,4179,4189,0,0]Ba[6270,13,2236,2236,4184,4189,0,0]Ba[6270,13,2236,2236,5359,4189,0,0]Ba[6270,13,5240,5240,2329,4189,0,0]Ba[6270,13,5755,5755,6528,4189,0,0]Ba[6270,13,2291,2291,4610,4189,0,0]Ba[6270,13,2291,2291,2336,4189,0,0]Ba[6270,13,2291,2291,5380,4189,0,0]Ba[6270,13,7276,7276,4203,4189,0,0]Ba[6270,13,7276,7276,6051,4189,0,0]Ba[6270,13,7276,7276,4194,4189,0,0]Ba[6270,13,7276,7276,6369,4189,0,0]Ba[6270,13,7276,7276,6656,4189,0,0]Ba[6270,13,6601,6601,5375,4189,0,0]Ba[6270,13,3505,3505,5385,4189,0,0]Ba[6270,13,3505,3505,3333,4189,0,0]Ba[6270,13,3505,3505,7065,4189,0,0]Ba[6270,13,3505,3505,6633,4189,0,0]An[6270,14,6496,6496,6077,0,0,0]An[6270,14,5242,5242,7049,0,0,0]An[6270,14,1016,1016,4648,0,0,0]An[6270,14,1016,1016,1013,0,0,0]An[6270,14,1016,1016,7062,0,0,0]An[6270,14,1016,1016,6038,0,0,0]An[6270,14,1732,1732,6376,0,0,0]An[6270,14,3399,3399,4609,0,0,0]An[6270,14,2794,2794,6636,0,0,0]An[6270,14,2794,2794,6638,0,0,0]An[6270,14,3046,3046,4649,0,0,0]An[6270,14,3046,3046,3369,0,0,0]An[6270,14,3046,3046,6322,0,0,0]An[6270,14,3046,3046,4613,0,0,0]An[6270,14,2789,2789,6028,0,0,0]An[6270,14,5323,5323,4192,0,0,0]An[6270,14,2997,2997,7442,0,0,0]An[6270,14,3012,3012,5367,0,0,0]An[6270,14,3398,3398,6032,0,0,0]An[6270,14,2627,2627,5381,0,0,0]An[6270,14,2627,2627,6519,0,0,0]An[6270,14,2236,2236,4179,0,0,0]An[6270,14,3015,3015,4614,0,0,0]An[6270,14,2291,2291,2856,0,0,0]An[6270,14,2291,2291,4610,0,0,0]An[6270,14,2291,2291,2336,0,0,0]An[6270,14,2291,2291,4180,0,0,0]An[6270,14,2291,2291,5360,0,0,0]An[6270,14,2291,2291,2008,0,0,0]An[6270,14,2291,2291,7864,0,0,0]An[6270,14,2787,2787,7931,0,0,0]An[6270,14,2787,2787,6037,0,0,0]An[6270,14,2787,2787,7559,0,0,0]An[6270,14,2787,2787,7050,0,0,0]An[6270,14,2784,2784,6055,0,0,0]Ba[6270,13,6271,6271,7037,4191,0,0]Ba[6270,13,1016,1016,5335,4191,0,0]Ba[6270,13,1016,1016,4648,4191,0,0]Ba[6270,13,1016,1016,1013,4191,0,0]Ba[6270,13,1016,1016,4612,4191,0,0]Ba[6270,13,1016,1016,2877,4191,0,0]Ba[6270,13,1016,1016,7062,4191,0,0]Ba[6270,13,1016,1016,8144,4191,0,0]Ba[6270,13,1016,1016,6038,4191,0,0]Ba[6270,13,7264,7264,6649,4191,0,0]Ba[6270,13,7264,7264,8047,4191,0,0]Ba[6270,13,1732,1732,6376,4191,0,0]Ba[6270,13,1017,1017,7437,4191,0,0]Ba[6270,13,1017,1017,7938,4191,0,0]Ba[6270,13,6111,6111,8145,4191,0,0]Ba[6270,13,3399,3399,7933,4191,0,0]Ba[6270,13,3399,3399,7563,4191,0,0]Ba[6270,13,3399,3399,7935,4191,0,0]Ba[6270,13,3399,3399,6331,4191,0,0]Ba[6270,13,3399,3399,7295,4191,0,0]Ba[6270,13,3399,3399,8290,4191,0,0]Ba[6270,13,3399,3399,7558,4191,0,0]Ba[6270,13,3399,3399,7769,4191,0,0]Ba[6270,13,3399,3399,4609,4191,0,0]Ba[6270,13,3399,3399,6076,4191,0,0]Ba[6270,13,3399,3399,7031,4191,0,0]Ba[6270,13,3399,3399,7768,4191,0,0]Ba[6270,13,5756,5756,6654,4191,0,0]Ba[6270,13,5756,5756,7552,4191,0,0]Ba[6270,13,5756,5756,7551,4191,0,0]Ba[6270,13,6453,6453,8147,4191,0,0]Ba[6270,13,6453,6453,7568,4191,0,0]Ba[6270,13,7500,7500,7992,4191,0,0]Ba[6270,13,2794,2794,7321,4191,0,0]Ba[6270,13,2794,2794,7454,4191,0,0]Ba[6270,13,2794,2794,8146,4191,0,0]Ba[6270,13,2794,2794,7784,4191,0,0]Ba[6270,13,2794,2794,7554,4191,0,0]Ba[6270,13,2794,2794,6054,4191,0,0]Ba[6270,13,2794,2794,6636,4191,0,0]Ba[6270,13,2794,2794,5378,4191,0,0]Ba[6270,13,2794,2794,6078,4191,0,0]Ba[6270,13,2794,2794,6638,4191,0,0]Ba[6270,13,2794,2794,7055,4191,0,0]Ba[6270,13,2794,2794,7056,4191,0,0]Ba[6270,13,2794,2794,7863,4191,0,0]Ba[6270,13,2794,2794,7304,4191,0,0]Ba[6270,13,2794,2794,7303,4191,0,0]Ba[6270,13,2794,2794,7325,4191,0,0]Ba[6270,13,2794,2794,8039,4191,0,0]Ba[6270,13,2794,2794,7869,4191,0,0]Ba[6270,13,2794,2794,7774,4191,0,0]Ba[6270,13,2794,2794,6368,4191,0,0]Ba[6270,13,2252,2252,7286,4191,0,0]Ba[6270,13,2252,2252,7550,4191,0,0]Ba[6270,13,2252,2252,7854,4191,0,0]Ba[6270,13,5293,5293,7312,4191,0,0]Ba[6270,13,5293,5293,8044,4191,0,0]Ba[6270,13,5293,5293,7317,4191,0,0]Ba[6270,13,5293,5293,7856,4191,0,0]Ba[6270,13,3046,3046,6627,4191,0,0]Ba[6270,13,3046,3046,7932,4191,0,0]Ba[6270,13,3046,3046,7871,4191,0,0]Ba[6270,13,3046,3046,4649,4191,0,0]Ba[6270,13,3046,3046,3369,4191,0,0]Ba[6270,13,3046,3046,6319,4191,0,0]Ba[6270,13,3046,3046,6523,4191,0,0]Ba[6270,13,3046,3046,7057,4191,0,0]Ba[6270,13,3046,3046,6322,4191,0,0]Ba[6270,13,3046,3046,4613,4191,0,0]Ba[6270,13,2789,2789,6028,4191,0,0]Ba[6270,13,2789,2789,7039,4191,0,0]Ba[6270,13,6304,6304,7867,4191,0,0]Ba[6270,13,5326,5326,6630,4191,0,0]Ba[6270,13,5654,5654,7292,4191,0,0]Ba[6270,13,5654,5654,6628,4191,0,0]Ba[6270,13,2902,2902,6512,4191,0,0]Ba[6270,13,2902,2902,7289,4191,0,0]Ba[6270,13,2902,2902,7290,4191,0,0]Ba[6270,13,2902,2902,7291,4191,0,0]Ba[6270,13,2902,2902,7455,4191,0,0]Ba[6270,13,4218,4218,6637,4191,0,0]Ba[6270,13,5323,5323,5377,4191,0,0]Ba[6270,13,5323,5323,3359,4191,0,0]Ba[6270,13,5323,5323,5342,4191,0,0]Ba[6270,13,5323,5323,4192,4191,0,0]Ba[6270,13,2997,2997,7442,4191,0,0]Ba[6270,13,3012,3012,5367,4191,0,0]Ba[6270,13,3498,3498,7779,4191,0,0]Ba[6270,13,6110,6110,7556,4191,0,0]Ba[6270,13,6110,6110,7440,4191,0,0]Ba[6270,13,6110,6110,7987,4191,0,0]Ba[6270,13,6110,6110,7766,4191,0,0]Ba[6270,13,5410,5410,6624,4191,0,0]Ba[6270,13,5410,5410,7040,4191,0,0]Ba[6270,13,5410,5410,6623,4191,0,0]Ba[6270,13,2235,2235,4182,4191,0,0]Ba[6270,13,2235,2235,4653,4191,0,0]Ba[6270,13,5732,5732,6045,4191,0,0]Ba[6270,13,3398,3398,6629,4191,0,0]Ba[6270,13,3398,3398,6032,4191,0,0]Ba[6270,13,3398,3398,6033,4191,0,0]Ba[6270,13,2052,2052,5337,4191,0,0]Ba[6270,13,2052,2052,4651,4191,0,0]Ba[6270,13,2644,2644,6330,4191,0,0]Ba[6270,13,3010,3010,5364,4191,0,0]Ba[6270,13,3010,3010,6048,4191,0,0]Ba[6270,13,3010,3010,7450,4191,0,0]Ba[6270,13,2841,2841,6506,4191,0,0]Ba[6270,13,2841,2841,6504,4191,0,0]Ba[6270,13,2841,2841,6328,4191,0,0]Ba[6270,13,2627,2627,6514,4191,0,0]Ba[6270,13,2627,2627,6042,4191,0,0]Ba[6270,13,2627,2627,5381,4191,0,0]Ba[6270,13,2627,2627,6519,4191,0,0]Ba[6270,13,2627,2627,4190,4191,0,0]Ba[6270,13,5683,5683,4630,4191,0,0]Ba[6270,13,5683,5683,4168,4191,0,0]Ba[6270,13,5683,5683,4628,4191,0,0]Ba[6270,13,2290,2290,6515,4191,0,0]Ba[6270,13,2290,2290,7776,4191,0,0]Ba[6270,13,2290,2290,7937,4191,0,0]Ba[6270,13,2290,2290,7991,4191,0,0]Ba[6270,13,6278,6278,7446,4191,0,0]Ba[6270,13,6278,6278,5356,4191,0,0]Ba[6270,13,6278,6278,3361,4191,0,0]Ba[6270,13,6278,6278,1007,4191,0,0]Ba[6270,13,6452,6452,8292,4191,0,0]Ba[6270,13,6452,6452,7567,4191,0,0]Ba[6270,13,6452,6452,6053,4191,0,0]Ba[6270,13,3494,3494,7059,4191,0,0]Ba[6270,13,3494,3494,6640,4191,0,0]Ba[6270,13,3494,3494,7307,4191,0,0]Ba[6270,13,6616,6616,4656,4191,0,0]Ba[6270,13,6616,6616,6375,4191,0,0]Ba[6270,13,3514,3514,6508,4191,0,0]Ba[6270,13,2793,2793,6069,4191,0,0]Ba[6270,13,2793,2793,7061,4191,0,0]Ba[6270,13,2793,2793,7445,4191,0,0]Ba[6270,13,2280,2280,4166,4191,0,0]Ba[6270,13,2280,2280,5374,4191,0,0]Ba[6270,13,1718,1718,7327,4191,0,0]Ba[6270,13,1718,1718,4650,4191,0,0]Ba[6270,13,2898,2898,5366,4191,0,0]Ba[6270,13,2898,2898,5371,4191,0,0]Ba[6270,13,6277,6277,2029,4191,0,0]Ba[6270,13,6277,6277,7060,4191,0,0]Ba[6270,13,6277,6277,6029,4191,0,0]Ba[6270,13,6277,6277,5369,4191,0,0]Ba[6270,13,2646,2646,7323,4191,0,0]Ba[6270,13,2646,2646,7310,4191,0,0]Ba[6270,13,5708,5708,4639,4191,0,0]Ba[6270,13,5708,5708,6335,4191,0,0]Ba[6270,13,6496,6496,6077,4191,0,0]Ba[6270,13,6496,6496,6079,4191,0,0]Ba[6270,13,3034,3034,7562,4191,0,0]Ba[6270,13,3034,3034,7048,4191,0,0]Ba[6270,13,2256,2256,4644,4191,0,0]Ba[6270,13,2256,2256,6062,4191,0,0]Ba[6270,13,2256,2256,5334,4191,0,0]Ba[6270,13,7334,7334,7038,4191,0,0]Ba[6270,13,2236,2236,6060,4191,0,0]Ba[6270,13,2236,2236,6373,4191,0,0]Ba[6270,13,2236,2236,4179,4191,0,0]Ba[6270,13,2236,2236,4184,4191,0,0]Ba[6270,13,2236,2236,5359,4191,0,0]Ba[6270,13,3392,3392,7034,4191,0,0]Ba[6270,13,3392,3392,7036,4191,0,0]Ba[6270,13,6431,6431,6041,4191,0,0]Ba[6270,13,5240,5240,4185,4191,0,0]Ba[6270,13,5240,5240,2358,4191,0,0]Ba[6270,13,5240,5240,2329,4191,0,0]Ba[6270,13,5755,5755,6528,4191,0,0]Ba[6270,13,5755,5755,6653,4191,0,0]Ba[6270,13,5316,5316,6049,4191,0,0]Ba[6270,13,5316,5316,6370,4191,0,0]An[6270,13,5242,5242,4,4191,0,0]Ba[6270,13,5242,5242,2361,4191,0,0]Ba[6270,13,5242,5242,7049,4191,0,0]Ba[6270,13,3015,3015,7301,4191,0,0]Ba[6270,13,3015,3015,4614,4191,0,0]Ba[6270,13,2291,2291,4196,4191,0,0]Ba[6270,13,2291,2291,2856,4191,0,0]Ba[6270,13,2291,2291,3368,4191,0,0]Ba[6270,13,2291,2291,4610,4191,0,0]Ba[6270,13,2291,2291,2336,4191,0,0]Ba[6270,13,2291,2291,4180,4191,0,0]Ba[6270,13,2291,2291,5361,4191,0,0]Ba[6270,13,2291,2291,5360,4191,0,0]Ba[6270,13,2291,2291,6073,4191,0,0]Ba[6270,13,2291,2291,7294,4191,0,0]Ba[6270,13,2291,2291,6631,4191,0,0]Ba[6270,13,2291,2291,5380,4191,0,0]Ba[6270,13,2291,2291,6371,4191,0,0]Ba[6270,13,2291,2291,6050,4191,0,0]Ba[6270,13,2291,2291,6516,4191,0,0]Ba[6270,13,2291,2291,2008,4191,0,0]Ba[6270,13,2291,2291,4174,4191,0,0]Ba[6270,13,2291,2291,6036,4191,0,0]Ba[6270,13,2291,2291,6367,4191,0,0]Ba[6270,13,2291,2291,7864,4191,0,0]Ba[6270,13,2787,2787,7053,4191,0,0]Ba[6270,13,2787,2787,4176,4191,0,0]Ba[6270,13,2787,2787,5348,4191,0,0]Ba[6270,13,2787,2787,6336,4191,0,0]Ba[6270,13,2787,2787,7931,4191,0,0]Ba[6270,13,2787,2787,7865,4191,0,0]Ba[6270,13,2787,2787,7772,4191,0,0]Ba[6270,13,2787,2787,6037,4191,0,0]Ba[6270,13,2787,2787,7045,4191,0,0]Ba[6270,13,2787,2787,4173,4191,0,0]Ba[6270,13,2787,2787,7560,4191,0,0]Ba[6270,13,2787,2787,7559,4191,0,0]Ba[6270,13,2787,2787,7564,4191,0,0]Ba[6270,13,2787,2787,6327,4191,0,0]Ba[6270,13,2787,2787,7293,4191,0,0]Ba[6270,13,2787,2787,7457,4191,0,0]Ba[6270,13,2787,2787,6657,4191,0,0]Ba[6270,13,2787,2787,7775,4191,0,0]Ba[6270,13,2787,2787,7050,4191,0,0]Ba[6270,13,2787,2787,7051,4191,0,0]Ba[6270,13,2658,2658,7044,4191,0,0]Ba[6270,13,7276,7276,4203,4191,0,0]Ba[6270,13,7276,7276,6051,4191,0,0]Ba[6270,13,7276,7276,4194,4191,0,0]Ba[6270,13,7276,7276,6369,4191,0,0]Ba[6270,13,7276,7276,6656,4191,0,0]Ba[6270,13,6601,6601,7778,4191,0,0]Ba[6270,13,6601,6601,5375,4191,0,0]Ba[6270,13,3505,3505,5385,4191,0,0]Ba[6270,13,3505,3505,3333,4191,0,0]Ba[6270,13,3505,3505,6321,4191,0,0]Ba[6270,13,3505,3505,7065,4191,0,0]Ba[6270,13,3505,3505,6377,4191,0,0]Ba[6270,13,3505,3505,6633,4191,0,0]Ba[6270,13,3045,3045,3325,4191,0,0]Ba[6270,13,5067,5067,5383,4191,0,0]Ba[6270,13,5067,5067,6645,4191,0,0]Ba[6270,13,5067,5067,2030,4191,0,0]Ba[6270,13,5067,5067,4642,4191,0,0]Ba[6270,13,2784,2784,6055,4191,0,0]Ba[6270,13,4589,4589,7571,4191,0,0]Ba[6270,13,2054,2054,6530,4191,0,0]Ba[6270,13,2054,2054,5338,4191,0,0]Ba[6270,13,2842,2842,6659,4191,0,0]Ba[6270,13,2842,2842,7939,4191,0,0]An[6270,19,3505,3505,3333,0,0,0]An[6270,19,3505,3505,7065,0,0,0]An[6270,15,2256,2256,4644,0,0,0]An[6270,15,2291,2291,5380,0,0,0]An[6270,15,2256,2256,5334,0,0,0]An[6270,15,5067,5067,3327,0,0,0]An[6270,15,7276,7276,4203,0,0,0]An[6270,15,5067,5067,4642,0,0,0]An[6270,15,2787,2787,4173,0,0,0]6An[6270,15,5755,5755,6528,0,0,0]7An[6270,15,3505,3505,3333,0,0,0]An[6270,13,2256,2256,6062,0,0,0]Ba[6270,13,6271,6271,7037,4191,0,0]Ba[6270,13,1016,1016,5335,4191,0,0]Ba[6270,13,1016,1016,4648,4191,0,0]Ba[6270,13,1016,1016,1013,4191,0,0]Ba[6270,13,1016,1016,4612,4191,0,0]Ba[6270,13,1016,1016,2877,4191,0,0]Ba[6270,13,1016,1016,7062,4191,0,0]Ba[6270,13,1016,1016,8144,4191,0,0]Ba[6270,13,1016,1016,6038,4191,0,0]Ba[6270,13,7264,7264,6649,4191,0,0]Ba[6270,13,7264,7264,8047,4191,0,0]Ba[6270,13,1732,1732,6376,4191,0,0]Ba[6270,13,1017,1017,7437,4191,0,0]Ba[6270,13,1017,1017,7938,4191,0,0]Ba[6270,13,6111,6111,8145,4191,0,0]Ba[6270,13,3399,3399,7933,4191,0,0]Ba[6270,13,3399,3399,7563,4191,0,0]Ba[6270,13,3399,3399,7935,4191,0,0]Ba[6270,13,3399,3399,6331,4191,0,0]Ba[6270,13,3399,3399,7295,4191,0,0]Ba[6270,13,3399,3399,8290,4191,0,0]Ba[6270,13,3399,3399,7558,4191,0,0]Ba[6270,13,3399,3399,7769,4191,0,0]Ba[6270,13,3399,3399,4609,4191,0,0]Ba[6270,13,3399,3399,6076,4191,0,0]Ba[6270,13,3399,3399,7031,4191,0,0]Ba[6270,13,3399,3399,7768,4191,0,0]Ba[6270,13,5756,5756,6654,4191,0,0]Ba[6270,13,5756,5756,7552,4191,0,0]Ba[6270,13,5756,5756,7551,4191,0,0]Ba[6270,13,6453,6453,8147,4191,0,0]Ba[6270,13,6453,6453,7568,4191,0,0]Ba[6270,13,7500,7500,7992,4191,0,0]Ba[6270,13,2794,2794,7321,4191,0,0]Ba[6270,13,2794,2794,7454,4191,0,0]Ba[6270,13,2794,2794,8146,4191,0,0]Ba[6270,13,2794,2794,7784,4191,0,0]Ba[6270,13,2794,2794,7554,4191,0,0]Ba[6270,13,2794,2794,6054,4191,0,0]Ba[6270,13,2794,2794,6636,4191,0,0]Ba[6270,13,2794,2794,5378,4191,0,0]Ba[6270,13,2794,2794,6078,4191,0,0]Ba[6270,13,2794,2794,6638,4191,0,0]Ba[6270,13,2794,2794,7055,4191,0,0]Ba[6270,13,2794,2794,7056,4191,0,0]Ba[6270,13,2794,2794,7863,4191,0,0]Ba[6270,13,2794,2794,7304,4191,0,0]Ba[6270,13,2794,2794,7303,4191,0,0]Ba[6270,13,2794,2794,7325,4191,0,0]Ba[6270,13,2794,2794,8039,4191,0,0]Ba[6270,13,2794,2794,7869,4191,0,0]Ba[6270,13,2794,2794,7774,4191,0,0]Ba[6270,13,2794,2794,6368,4191,0,0]Ba[6270,13,2252,2252,7286,4191,0,0]Ba[6270,13,2252,2252,7550,4191,0,0]Ba[6270,13,2252,2252,7854,4191,0,0]Ba[6270,13,5293,5293,7312,4191,0,0]Ba[6270,13,5293,5293,8044,4191,0,0]Ba[6270,13,5293,5293,7317,4191,0,0]Ba[6270,13,5293,5293,7856,4191,0,0]Ba[6270,13,3046,3046,6627,4191,0,0]Ba[6270,13,3046,3046,7932,4191,0,0]Ba[6270,13,3046,3046,7871,4191,0,0]Ba[6270,13,3046,3046,4649,4191,0,0]Ba[6270,13,3046,3046,3369,4191,0,0]Ba[6270,13,3046,3046,6319,4191,0,0]Ba[6270,13,3046,3046,6523,4191,0,0]Ba[6270,13,3046,3046,7057,4191,0,0]Ba[6270,13,3046,3046,6322,4191,0,0]Ba[6270,13,3046,3046,4613,4191,0,0]Ba[6270,13,2789,2789,6028,4191,0,0]Ba[6270,13,2789,2789,7039,4191,0,0]Ba[6270,13,6304,6304,7867,4191,0,0]Ba[6270,13,5326,5326,6630,4191,0,0]Ba[6270,13,5654,5654,7292,4191,0,0]Ba[6270,13,5654,5654,6628,4191,0,0]Ba[6270,13,2902,2902,6512,4191,0,0]Ba[6270,13,2902,2902,7289,4191,0,0]Ba[6270,13,2902,2902,7290,4191,0,0]Ba[6270,13,2902,2902,7291,4191,0,0]Ba[6270,13,2902,2902,7455,4191,0,0]Ba[6270,13,4218,4218,6637,4191,0,0]Ba[6270,13,5323,5323,5377,4191,0,0]Ba[6270,13,5323,5323,3359,4191,0,0]Ba[6270,13,5323,5323,5342,4191,0,0]Ba[6270,13,5323,5323,4192,4191,0,0]Ba[6270,13,2997,2997,7442,4191,0,0]Ba[6270,13,3012,3012,5367,4191,0,0]Ba[6270,13,3498,3498,7779,4191,0,0]Ba[6270,13,6110,6110,7556,4191,0,0]Ba[6270,13,6110,6110,7440,4191,0,0]Ba[6270,13,6110,6110,7987,4191,0,0]Ba[6270,13,6110,6110,7766,4191,0,0]Ba[6270,13,5410,5410,6624,4191,0,0]Ba[6270,13,5410,5410,7040,4191,0,0]Ba[6270,13,5410,5410,6623,4191,0,0]Ba[6270,13,2235,2235,4182,4191,0,0]Ba[6270,13,2235,2235,4653,4191,0,0]Ba[6270,13,5732,5732,6045,4191,0,0]Ba[6270,13,3398,3398,6629,4191,0,0]Ba[6270,13,3398,3398,6032,4191,0,0]Ba[6270,13,3398,3398,6033,4191,0,0]Ba[6270,13,2052,2052,5337,4191,0,0]Ba[6270,13,2052,2052,4651,4191,0,0]Ba[6270,13,2644,2644,6330,4191,0,0]Ba[6270,13,3010,3010,5364,4191,0,0]Ba[6270,13,3010,3010,6048,4191,0,0]Ba[6270,13,3010,3010,7450,4191,0,0]Ba[6270,13,2841,2841,6506,4191,0,0]Ba[6270,13,2841,2841,6504,4191,0,0]Ba[6270,13,2841,2841,6328,4191,0,0]Ba[6270,13,2627,2627,6514,4191,0,0]Ba[6270,13,2627,2627,6042,4191,0,0]Ba[6270,13,2627,2627,5381,4191,0,0]Ba[6270,13,2627,2627,6519,4191,0,0]Ba[6270,13,2627,2627,4190,4191,0,0]Ba[6270,13,5683,5683,4630,4191,0,0]Ba[6270,13,5683,5683,4168,4191,0,0]Ba[6270,13,5683,5683,4628,4191,0,0]Ba[6270,13,2290,2290,6515,4191,0,0]Ba[6270,13,2290,2290,7776,4191,0,0]Ba[6270,13,2290,2290,7937,4191,0,0]Ba[6270,13,2290,2290,7991,4191,0,0]Ba[6270,13,6278,6278,7446,4191,0,0]Ba[6270,13,6278,6278,5356,4191,0,0]Ba[6270,13,6278,6278,3361,4191,0,0]Ba[6270,13,6278,6278,1007,4191,0,0]Ba[6270,13,6452,6452,8292,4191,0,0]Ba[6270,13,6452,6452,7567,4191,0,0]Ba[6270,13,6452,6452,6053,4191,0,0]Ba[6270,13,3494,3494,7059,4191,0,0]Ba[6270,13,3494,3494,6640,4191,0,0]Ba[6270,13,3494,3494,7307,4191,0,0]Ba[6270,13,6616,6616,4656,4191,0,0]Ba[6270,13,6616,6616,6375,4191,0,0]Ba[6270,13,3514,3514,6508,4191,0,0]Ba[6270,13,2793,2793,6069,4191,0,0]Ba[6270,13,2793,2793,7061,4191,0,0]Ba[6270,13,2793,2793,7445,4191,0,0]Ba[6270,13,2280,2280,4166,4191,0,0]Ba[6270,13,2280,2280,5374,4191,0,0]Ba[6270,13,1718,1718,7327,4191,0,0]Ba[6270,13,1718,1718,4650,4191,0,0]Ba[6270,13,2898,2898,5366,4191,0,0]Ba[6270,13,2898,2898,5371,4191,0,0]Ba[6270,13,6277,6277,2029,4191,0,0]Ba[6270,13,6277,6277,7060,4191,0,0]Ba[6270,13,6277,6277,6029,4191,0,0]Ba[6270,13,6277,6277,5369,4191,0,0]Ba[6270,13,2646,2646,7323,4191,0,0]Ba[6270,13,2646,2646,7310,4191,0,0]Ba[6270,13,5708,5708,4639,4191,0,0]Ba[6270,13,5708,5708,6335,4191,0,0]Ba[6270,13,6496,6496,6077,4191,0,0]Ba[6270,13,6496,6496,6079,4191,0,0]Ba[6270,13,3034,3034,7562,4191,0,0]Ba[6270,13,3034,3034,7048,4191,0,0]Ba[6270,13,2256,2256,4644,4191,0,0]Ba[6270,13,2256,2256,6062,4191,0,0]Ba[6270,13,2256,2256,5334,4191,0,0]Ba[6270,13,7334,7334,7038,4191,0,0]Ba[6270,13,2236,2236,6060,4191,0,0]Ba[6270,13,2236,2236,6373,4191,0,0]Ba[6270,13,2236,2236,4179,4191,0,0]Ba[6270,13,2236,2236,4184,4191,0,0]Ba[6270,13,2236,2236,5359,4191,0,0]Ba[6270,13,3392,3392,7034,4191,0,0]Ba[6270,13,3392,3392,7036,4191,0,0]Ba[6270,13,6431,6431,6041,4191,0,0]Ba[6270,13,5240,5240,4185,4191,0,0]Ba[6270,13,5240,5240,2358,4191,0,0]Ba[6270,13,5240,5240,2329,4191,0,0]Ba[6270,13,5755,5755,6528,4191,0,0]Ba[6270,13,5755,5755,6653,4191,0,0]Ba[6270,13,5316,5316,6049,4191,0,0]Ba[6270,13,5316,5316,6370,4191,0,0]An[6270,13,5242,5242,4,4191,0,0]Ba[6270,13,5242,5242,2361,4191,0,0]Ba[6270,13,5242,5242,7049,4191,0,0]Ba[6270,13,3015,3015,7301,4191,0,0]Ba[6270,13,3015,3015,4614,4191,0,0]Ba[6270,13,2291,2291,4196,4191,0,0]Ba[6270,13,2291,2291,2856,4191,0,0]Ba[6270,13,2291,2291,3368,4191,0,0]Ba[6270,13,2291,2291,4610,4191,0,0]Ba[6270,13,2291,2291,2336,4191,0,0]Ba[6270,13,2291,2291,4180,4191,0,0]Ba[6270,13,2291,2291,5361,4191,0,0]Ba[6270,13,2291,2291,5360,4191,0,0]Ba[6270,13,2291,2291,6073,4191,0,0]Ba[6270,13,2291,2291,7294,4191,0,0]Ba[6270,13,2291,2291,6631,4191,0,0]Ba[6270,13,2291,2291,5380,4191,0,0]Ba[6270,13,2291,2291,6371,4191,0,0]Ba[6270,13,2291,2291,6050,4191,0,0]Ba[6270,13,2291,2291,6516,4191,0,0]Ba[6270,13,2291,2291,2008,4191,0,0]Ba[6270,13,2291,2291,4174,4191,0,0]Ba[6270,13,2291,2291,6036,4191,0,0]Ba[6270,13,2291,2291,6367,4191,0,0]Ba[6270,13,2291,2291,7864,4191,0,0]Ba[6270,13,2787,2787,7053,4191,0,0]Ba[6270,13,2787,2787,4176,4191,0,0]Ba[6270,13,2787,2787,5348,4191,0,0]Ba[6270,13,2787,2787,6336,4191,0,0]Ba[6270,13,2787,2787,7931,4191,0,0]Ba[6270,13,2787,2787,7865,4191,0,0]Ba[6270,13,2787,2787,7772,4191,0,0]Ba[6270,13,2787,2787,6037,4191,0,0]Ba[6270,13,2787,2787,7045,4191,0,0]Ba[6270,13,2787,2787,4173,4191,0,0]Ba[6270,13,2787,2787,7560,4191,0,0]Ba[6270,13,2787,2787,7559,4191,0,0]Ba[6270,13,2787,2787,7564,4191,0,0]Ba[6270,13,2787,2787,6327,4191,0,0]Ba[6270,13,2787,2787,7293,4191,0,0]Ba[6270,13,2787,2787,7457,4191,0,0]Ba[6270,13,2787,2787,6657,4191,0,0]Ba[6270,13,2787,2787,7775,4191,0,0]Ba[6270,13,2787,2787,7050,4191,0,0]Ba[6270,13,2787,2787,7051,4191,0,0]Ba[6270,13,2658,2658,7044,4191,0,0]Ba[6270,13,7276,7276,4203,4191,0,0]Ba[6270,13,7276,7276,6051,4191,0,0]Ba[6270,13,7276,7276,4194,4191,0,0]Ba[6270,13,7276,7276,6369,4191,0,0]Ba[6270,13,7276,7276,6656,4191,0,0]Ba[6270,13,6601,6601,7778,4191,0,0]Ba[6270,13,6601,6601,5375,4191,0,0]Ba[6270,13,3505,3505,5385,4191,0,0]Ba[6270,13,3505,3505,3333,4191,0,0]Ba[6270,13,3505,3505,6321,4191,0,0]Ba[6270,13,3505,3505,7065,4191,0,0]Ba[6270,13,3505,3505,6377,4191,0,0]Ba[6270,13,3505,3505,6633,4191,0,0]Ba[6270,13,3045,3045,3325,4191,0,0]Ba[6270,13,5067,5067,5383,4191,0,0]Ba[6270,13,5067,5067,6645,4191,0,0]Ba[6270,13,5067,5067,2030,4191,0,0]Ba[6270,13,5067,5067,4642,4191,0,0]Ba[6270,13,2784,2784,6055,4191,0,0]Ba[6270,13,4589,4589,7571,4191,0,0]Ba[6270,13,2054,2054,6530,4191,0,0]Ba[6270,13,2054,2054,5338,4191,0,0]Ba[6270,13,2842,2842,6659,4191,0,0]Ba[6270,13,2842,2842,7939,4191,0,0]B`[6270,13,5732,5732,6045,353,0,0]An[6270,14,5242,5242,7049,0,0,0]An[6270,14,6278,6278,1007,0,0,0]An[6270,14,5242,5242,2361,0,0,0]Ba[6270,13,1016,1016,4648,1995,0,0]Ba[6270,13,1016,1016,1013,1995,0,0]Ba[6270,13,1016,1016,4612,1995,0,0]Ba[6270,13,1016,1016,2877,1995,0,0]Ba[6270,13,1016,1016,7062,1995,0,0]Ba[6270,13,1016,1016,8144,1995,0,0]Ba[6270,13,1016,1016,6038,1995,0,0]Ba[6270,13,7264,7264,8047,1995,0,0]Ba[6270,13,1732,1732,6376,1995,0,0]Ba[6270,13,3399,3399,7935,1995,0,0]Ba[6270,13,3399,3399,6331,1995,0,0]Ba[6270,13,3399,3399,7295,1995,0,0]Ba[6270,13,3399,3399,8290,1995,0,0]Ba[6270,13,3399,3399,7558,1995,0,0]Ba[6270,13,3399,3399,7769,1995,0,0]Ba[6270,13,3399,3399,4609,1995,0,0]Ba[6270,13,3399,3399,6076,1995,0,0]Ba[6270,13,3399,3399,7031,1995,0,0]Ba[6270,13,3399,3399,7768,1995,0,0]Ba[6270,13,5756,5756,6654,1995,0,0]Ba[6270,13,5756,5756,7552,1995,0,0]Ba[6270,13,5756,5756,7551,1995,0,0]Ba[6270,13,2794,2794,6054,1995,0,0]Ba[6270,13,2794,2794,6636,1995,0,0]Ba[6270,13,2794,2794,5378,1995,0,0]Ba[6270,13,2794,2794,6078,1995,0,0]Ba[6270,13,2794,2794,6638,1995,0,0]Ba[6270,13,2794,2794,7055,1995,0,0]Ba[6270,13,2794,2794,7056,1995,0,0]Ba[6270,13,2794,2794,7863,1995,0,0]Ba[6270,13,2794,2794,7304,1995,0,0]Ba[6270,13,2794,2794,7303,1995,0,0]Ba[6270,13,2794,2794,7325,1995,0,0]Ba[6270,13,2794,2794,8039,1995,0,0]Ba[6270,13,2794,2794,7869,1995,0,0]Ba[6270,13,2794,2794,7774,1995,0,0]Ba[6270,13,2794,2794,6368,1995,0,0]Ba[6270,13,2252,2252,7286,1995,0,0]Ba[6270,13,2252,2252,7550,1995,0,0]Ba[6270,13,2252,2252,7854,1995,0,0]Ba[6270,13,5293,5293,7317,1995,0,0]Ba[6270,13,5293,5293,7856,1995,0,0]Ba[6270,13,3046,3046,4649,1995,0,0]Ba[6270,13,3046,3046,3369,1995,0,0]Ba[6270,13,3046,3046,6319,1995,0,0]Ba[6270,13,3046,3046,6523,1995,0,0]Ba[6270,13,3046,3046,7057,1995,0,0]Ba[6270,13,3046,3046,6322,1995,0,0]Ba[6270,13,3046,3046,4613,1995,0,0]Ba[6270,13,2789,2789,6028,1995,0,0]Ba[6270,13,2789,2789,7039,1995,0,0]Ba[6270,13,6304,6304,7867,1995,0,0]Ba[6270,13,5326,5326,6630,1995,0,0]Ba[6270,13,5654,5654,7292,1995,0,0]Ba[6270,13,5654,5654,6628,1995,0,0]Ba[6270,13,2902,2902,7289,1995,0,0]Ba[6270,13,2902,2902,7290,1995,0,0]Ba[6270,13,2902,2902,7291,1995,0,0]Ba[6270,13,2902,2902,7455,1995,0,0]Ba[6270,13,4218,4218,6637,1995,0,0]Ba[6270,13,5323,5323,3359,1995,0,0]Ba[6270,13,5323,5323,5342,1995,0,0]Ba[6270,13,5323,5323,4192,1995,0,0]Ba[6270,13,2997,2997,7442,1995,0,0]Ba[6270,13,3012,3012,5367,1995,0,0]Ba[6270,13,6110,6110,7440,1995,0,0]Ba[6270,13,6110,6110,7987,1995,0,0]Ba[6270,13,5410,5410,7040,1995,0,0]Dd[6270,13,5410,5410,6623,2848,0,0,\"impl-Display-for-CredentialError\"]Db[6270,13,5410,5410,6623,1995,0,0,\"impl-Debug-for-CredentialError\"]Ba[6270,13,2235,2235,4182,1995,0,0]Ba[6270,13,2235,2235,4653,1995,0,0]Ba[6270,13,5732,5732,6045,1995,0,0]Ba[6270,13,3398,3398,6032,1995,0,0]Ba[6270,13,3398,3398,6033,1995,0,0]Ba[6270,13,2052,2052,5337,1995,0,0]Ba[6270,13,2644,2644,6330,1995,0,0]Ba[6270,13,3010,3010,5364,1995,0,0]Ba[6270,13,3010,3010,6048,1995,0,0]Ba[6270,13,2841,2841,6504,1995,0,0]D`[6270,13,2841,2841,6328,1995,0,0,\"impl-Debug-for-DownloadError\"]Db[6270,13,2841,2841,6328,2848,0,0,\"impl-Display-for-DownloadError\"]Ba[6270,13,2627,2627,6042,1995,0,0]Ba[6270,13,2627,2627,5381,1995,0,0]Ba[6270,13,2627,2627,6519,1995,0,0]Ba[6270,13,2627,2627,4190,1995,0,0]Ba[6270,13,5683,5683,4630,1995,0,0]Ba[6270,13,5683,5683,4168,1995,0,0]Ba[6270,13,2290,2290,6515,1995,0,0]Ba[6270,13,2290,2290,7776,1995,0,0]Ba[6270,13,2290,2290,7937,1995,0,0]Ba[6270,13,2290,2290,7991,1995,0,0]Ba[6270,13,6278,6278,3361,1995,0,0]Ba[6270,13,6278,6278,1007,1995,0,0]Ba[6270,13,6452,6452,6053,1995,0,0]Ba[6270,13,3494,3494,6640,1995,0,0]Ba[6270,13,3494,3494,7307,1995,0,0]Ba[6270,13,2793,2793,7445,1995,0,0]Ba[6270,13,2280,2280,4166,1995,0,0]Ba[6270,13,2280,2280,5374,1995,0,0]Ba[6270,13,1718,1718,7327,1995,0,0]Ba[6270,13,1718,1718,4650,1995,0,0]D`[6270,13,2898,2898,5371,2848,0,0,\"impl-Display-for-PlayerError\"]Cn[6270,13,2898,2898,5371,1995,0,0,\"impl-Debug-for-PlayerError\"]Ba[6270,13,6277,6277,2029,1995,0,0]Ba[6270,13,6277,6277,7060,1995,0,0]Ba[6270,13,6277,6277,6029,1995,0,0]Ba[6270,13,6277,6277,5369,1995,0,0]Ba[6270,13,2646,2646,7310,1995,0,0]Ba[6270,13,3034,3034,7048,1995,0,0]Ba[6270,13,2256,2256,4644,1995,0,0]Ba[6270,13,2256,2256,6062,1995,0,0]Ba[6270,13,2256,2256,5334,1995,0,0]Ba[6270,13,7334,7334,7038,1995,0,0]Ba[6270,13,2236,2236,6060,1995,0,0]Ba[6270,13,2236,2236,6373,1995,0,0]Ba[6270,13,2236,2236,4179,1995,0,0]Ba[6270,13,2236,2236,4184,1995,0,0]Ba[6270,13,2236,2236,5359,1995,0,0]Ba[6270,13,3392,3392,7034,1995,0,0]Ba[6270,13,3392,3392,7036,1995,0,0]Ba[6270,13,5240,5240,2358,1995,0,0]Ba[6270,13,5240,5240,2329,1995,0,0]Ba[6270,13,5755,5755,6528,1995,0,0]Ba[6270,13,5755,5755,6653,1995,0,0]Ba[6270,13,5316,5316,6049,1995,0,0]Ba[6270,13,5316,5316,6370,1995,0,0]Ba[6270,13,3015,3015,4614,1995,0,0]Cn[6270,13,2291,2291,4196,2848,0,0,\"impl-Display-for-RepoError\"]Cl[6270,13,2291,2291,4196,1995,0,0,\"impl-Debug-for-RepoError\"]Ba[6270,13,2291,2291,2856,1995,0,0]Ba[6270,13,2291,2291,3368,1995,0,0]Ba[6270,13,2291,2291,4610,1995,0,0]Ba[6270,13,2291,2291,2336,1995,0,0]Ba[6270,13,2291,2291,4180,1995,0,0]Ba[6270,13,2291,2291,5361,1995,0,0]Ba[6270,13,2291,2291,5360,1995,0,0]Ba[6270,13,2291,2291,6073,1995,0,0]Ba[6270,13,2291,2291,7294,1995,0,0]Ba[6270,13,2291,2291,6631,1995,0,0]Ba[6270,13,2291,2291,5380,1995,0,0]Ba[6270,13,2291,2291,6371,1995,0,0]Ba[6270,13,2291,2291,6050,1995,0,0]Ba[6270,13,2291,2291,6516,1995,0,0]Ba[6270,13,2291,2291,2008,1995,0,0]Ba[6270,13,2291,2291,4174,1995,0,0]Ba[6270,13,2291,2291,6036,1995,0,0]Ba[6270,13,2291,2291,6367,1995,0,0]Ba[6270,13,2291,2291,7864,1995,0,0]Ba[6270,13,2787,2787,4176,1995,0,0]Ba[6270,13,2787,2787,5348,1995,0,0]Ba[6270,13,2787,2787,6336,1995,0,0]Ba[6270,13,2787,2787,7931,1995,0,0]Ba[6270,13,2787,2787,7865,1995,0,0]Ba[6270,13,2787,2787,7772,1995,0,0]Ba[6270,13,2787,2787,6037,1995,0,0]Ba[6270,13,2787,2787,7045,1995,0,0]Ba[6270,13,2787,2787,4173,1995,0,0]Ba[6270,13,2787,2787,7560,1995,0,0]Ba[6270,13,2787,2787,7559,1995,0,0]Ba[6270,13,2787,2787,7564,1995,0,0]Ba[6270,13,2787,2787,6327,1995,0,0]Ba[6270,13,2787,2787,7293,1995,0,0]Ba[6270,13,2787,2787,7457,1995,0,0]Ba[6270,13,2787,2787,6657,1995,0,0]Ba[6270,13,2787,2787,7775,1995,0,0]Ba[6270,13,2787,2787,7050,1995,0,0]Ba[6270,13,2787,2787,7051,1995,0,0]Ba[6270,13,7276,7276,4203,1995,0,0]Ba[6270,13,7276,7276,6051,1995,0,0]Ba[6270,13,7276,7276,4194,1995,0,0]Ba[6270,13,7276,7276,6369,1995,0,0]Ba[6270,13,7276,7276,6656,1995,0,0]Ba[6270,13,6601,6601,5375,1995,0,0]Ba[6270,13,3505,3505,5385,1995,0,0]Ba[6270,13,3505,3505,3333,1995,0,0]Ba[6270,13,3505,3505,6321,1995,0,0]Ba[6270,13,3505,3505,7065,1995,0,0]Ba[6270,13,3505,3505,6377,1995,0,0]Ba[6270,13,3505,3505,6633,1995,0,0]Ba[6270,13,5067,5067,4642,1995,0,0]Ba[6270,13,2784,2784,6055,1995,0,0]Ba[6270,13,4589,4589,7571,1995,0,0]Ba[6270,13,2054,2054,5338,1995,0,0]Dk[6270,13,2842,2842,7939,2848,0,0,\"impl-Display-for-ThumbnailDownloadError\"]Di[6270,13,2842,2842,7939,1995,0,0,\"impl-Debug-for-ThumbnailDownloadError\"]An[6270,13,5293,5293,7312,0,0,0]An[6270,13,3010,3010,7450,0,0,0]An[6270,13,2627,2627,6514,0,0,0]An[6270,13,6278,6278,1007,0,0,0]An[6270,13,6277,6277,6029,0,0,0]An[6270,14,5708,5708,4639,0,0,0]An[6270,14,6496,6496,6077,0,0,0]An[6270,14,5240,5240,4185,0,0,0]An[6270,13,1016,1016,5335,0,0,0]An[6270,13,7264,7264,6649,0,0,0]9An[6270,13,6110,6110,7556,0,0,0]An[6270,13,6110,6110,7766,0,0,0]An[6270,13,5410,5410,6624,0,0,0]An[6270,13,3398,3398,6629,0,0,0]An[6270,13,2052,2052,4651,0,0,0]=An[6270,13,2841,2841,6506,0,0,0]=An[6270,13,5683,5683,4628,0,0,0]An[6270,13,6452,6452,7567,0,0,0]An[6270,13,3494,3494,7059,0,0,0]An[6270,13,6616,6616,6375,0,0,0]An[6270,13,3514,3514,6508,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,13,2898,2898,5366,0,0,0]An[6270,13,6277,6277,5369,0,0,0]An[6270,13,2646,2646,7323,0,0,0]An[6270,13,5708,5708,4639,0,0,0]An[6270,13,3034,3034,7562,0,0,0]An[6270,13,2256,2256,6062,0,0,0]An[6270,13,6431,6431,6041,0,0,0]An[6270,13,5240,5240,4185,0,0,0]An[6270,13,5242,5242,7049,0,0,0]An[6270,13,3015,3015,7301,0,0,0]An[6270,13,2291,2291,2856,0,0,0]An[6270,13,2787,2787,7053,0,0,0]An[6270,13,2658,2658,7044,0,0,0]An[6270,13,6601,6601,7778,0,0,0]An[6270,13,5067,5067,5383,0,0,0]An[6270,13,5067,5067,6645,0,0,0]An[6270,13,5067,5067,2030,0,0,0]An[6270,13,2054,2054,6530,0,0,0]An[6270,13,2842,2842,6659,0,0,0]Ag[6270,7,6271,0,0,0,0,0]An[6270,13,3010,3010,7450,0,0,0]Ba[6270,13,1016,1016,4648,4187,0,0]Ba[6270,13,1016,1016,1013,4187,0,0]Ba[6270,13,1016,1016,4612,4187,0,0]Ba[6270,13,1016,1016,2877,4187,0,0]Ba[6270,13,1016,1016,7062,4187,0,0]Ba[6270,13,1016,1016,8144,4187,0,0]Ba[6270,13,1016,1016,6038,4187,0,0]Ba[6270,13,1732,1732,6376,4187,0,0]Ba[6270,13,3399,3399,7935,4187,0,0]Ba[6270,13,3399,3399,6331,4187,0,0]Ba[6270,13,3399,3399,7295,4187,0,0]Ba[6270,13,3399,3399,8290,4187,0,0]Ba[6270,13,3399,3399,7558,4187,0,0]Ba[6270,13,3399,3399,7769,4187,0,0]Ba[6270,13,3399,3399,6076,4187,0,0]Ba[6270,13,3399,3399,7031,4187,0,0]Ba[6270,13,3399,3399,7768,4187,0,0]Ba[6270,13,5756,5756,6654,4187,0,0]Ba[6270,13,5756,5756,7552,4187,0,0]Ba[6270,13,5756,5756,7551,4187,0,0]Ba[6270,13,6453,6453,7568,4187,0,0]Ba[6270,13,2794,2794,6054,4187,0,0]Ba[6270,13,2794,2794,6636,4187,0,0]Ba[6270,13,2794,2794,5378,4187,0,0]Ba[6270,13,2794,2794,6078,4187,0,0]Ba[6270,13,2794,2794,6638,4187,0,0]Ba[6270,13,2794,2794,7055,4187,0,0]Ba[6270,13,2794,2794,7056,4187,0,0]Ba[6270,13,2794,2794,7863,4187,0,0]Ba[6270,13,2794,2794,7304,4187,0,0]Ba[6270,13,2794,2794,7303,4187,0,0]Ba[6270,13,2794,2794,7325,4187,0,0]Ba[6270,13,2794,2794,8039,4187,0,0]Ba[6270,13,2794,2794,7869,4187,0,0]Ba[6270,13,2794,2794,7774,4187,0,0]Ba[6270,13,2794,2794,6368,4187,0,0]Ba[6270,13,2252,2252,7286,4187,0,0]Ba[6270,13,2252,2252,7550,4187,0,0]Ba[6270,13,2252,2252,7854,4187,0,0]Ba[6270,13,5293,5293,7317,4187,0,0]Ba[6270,13,5293,5293,7856,4187,0,0]Ba[6270,13,3046,3046,4649,4187,0,0]Ba[6270,13,3046,3046,3369,4187,0,0]Ba[6270,13,3046,3046,6319,4187,0,0]Ba[6270,13,3046,3046,6523,4187,0,0]Ba[6270,13,3046,3046,7057,4187,0,0]Ba[6270,13,3046,3046,6322,4187,0,0]Ba[6270,13,3046,3046,4613,4187,0,0]Ba[6270,13,2789,2789,6028,4187,0,0]Ba[6270,13,2789,2789,7039,4187,0,0]Ba[6270,13,6304,6304,7867,4187,0,0]Ba[6270,13,5326,5326,6630,4187,0,0]Ba[6270,13,5654,5654,7292,4187,0,0]Ba[6270,13,5654,5654,6628,4187,0,0]Ba[6270,13,2902,2902,7289,4187,0,0]Ba[6270,13,2902,2902,7290,4187,0,0]Ba[6270,13,2902,2902,7291,4187,0,0]Ba[6270,13,2902,2902,7455,4187,0,0]Ba[6270,13,5323,5323,5342,4187,0,0]Ba[6270,13,2997,2997,7442,4187,0,0]Ba[6270,13,3012,3012,5367,4187,0,0]Ba[6270,13,6110,6110,7440,4187,0,0]Ba[6270,13,6110,6110,7987,4187,0,0]Ba[6270,13,2235,2235,4182,4187,0,0]Ba[6270,13,2235,2235,4653,4187,0,0]Ba[6270,13,3398,3398,6032,4187,0,0]Ba[6270,13,2052,2052,5337,4187,0,0]Ba[6270,13,3010,3010,5364,4187,0,0]Ba[6270,13,3010,3010,6048,4187,0,0]Ba[6270,13,2627,2627,6042,4187,0,0]Ba[6270,13,2627,2627,5381,4187,0,0]Ba[6270,13,2627,2627,6519,4187,0,0]Ba[6270,13,2627,2627,4190,4187,0,0]Ba[6270,13,6452,6452,6053,4187,0,0]Ba[6270,13,2280,2280,4166,4187,0,0]Ba[6270,13,2280,2280,5374,4187,0,0]Ba[6270,13,2646,2646,7310,4187,0,0]Ba[6270,13,3034,3034,7048,4187,0,0]Ba[6270,13,2256,2256,4644,4187,0,0]Ba[6270,13,2256,2256,6062,4187,0,0]Ba[6270,13,7334,7334,7038,4187,0,0]Ba[6270,13,2236,2236,6060,4187,0,0]Ba[6270,13,2236,2236,6373,4187,0,0]Ba[6270,13,2236,2236,4179,4187,0,0]Ba[6270,13,2236,2236,4184,4187,0,0]Ba[6270,13,2236,2236,5359,4187,0,0]Ba[6270,13,3392,3392,7034,4187,0,0]Ba[6270,13,3392,3392,7036,4187,0,0]Ba[6270,13,5755,5755,6528,4187,0,0]Ba[6270,13,5755,5755,6653,4187,0,0]Ba[6270,13,2291,2291,4196,4187,0,0]Ba[6270,13,2291,2291,2856,4187,0,0]Ba[6270,13,2291,2291,3368,4187,0,0]Ba[6270,13,2291,2291,4610,4187,0,0]Ba[6270,13,2291,2291,2336,4187,0,0]Ba[6270,13,2291,2291,4180,4187,0,0]Ba[6270,13,2291,2291,5361,4187,0,0]Ba[6270,13,2291,2291,5360,4187,0,0]Ba[6270,13,2291,2291,6073,4187,0,0]Ba[6270,13,2291,2291,7294,4187,0,0]Ba[6270,13,2291,2291,6631,4187,0,0]Ba[6270,13,2291,2291,5380,4187,0,0]Ba[6270,13,2291,2291,6371,4187,0,0]Ba[6270,13,2291,2291,6050,4187,0,0]Ba[6270,13,2291,2291,6516,4187,0,0]Ba[6270,13,2291,2291,2008,4187,0,0]Ba[6270,13,2291,2291,4174,4187,0,0]Ba[6270,13,2291,2291,6036,4187,0,0]Ba[6270,13,2291,2291,6367,4187,0,0]Ba[6270,13,2291,2291,7864,4187,0,0]Ba[6270,13,2787,2787,4176,4187,0,0]Ba[6270,13,7276,7276,4203,4187,0,0]Ba[6270,13,7276,7276,6051,4187,0,0]Ba[6270,13,7276,7276,4194,4187,0,0]Ba[6270,13,7276,7276,6369,4187,0,0]Ba[6270,13,7276,7276,6656,4187,0,0]Ba[6270,13,3505,3505,5385,4187,0,0]Ba[6270,13,3505,3505,3333,4187,0,0]Ba[6270,13,3505,3505,6321,4187,0,0]Ba[6270,13,3505,3505,7065,4187,0,0]Ba[6270,13,3505,3505,6377,4187,0,0]Ba[6270,13,3505,3505,6633,4187,0,0]Ba[6270,13,4589,4589,7571,4187,0,0]An[6270,14,5067,5067,2030,0,0,0]An[6270,14,5326,5326,6630,0,0,0]An[6270,14,2291,2291,6036,0,0,0]An[6270,14,3046,3046,4649,0,0,0]An[6270,14,3398,3398,6033,0,0,0]Ak[6270,14,7308,0,7070,0,0,0]An[6270,14,2236,2236,6373,0,0,0]Ak[6270,14,5357,0,4163,0,0,0]An[6270,14,7276,7276,6656,0,0,0]An[6270,15,5067,5067,4642,0,0,0]An[6270,15,3505,3505,3333,0,0,0]An[6270,15,2841,2841,6328,0,0,0]An[6270,15,2842,2842,7939,0,0,0]An[6270,15,6452,6452,6053,0,0,0]An[6270,15,2280,2280,5374,0,0,0]An[6270,15,6277,6277,2029,0,0,0]An[6270,15,3034,3034,7048,0,0,0]6An[6270,15,2291,2291,4174,0,0,0]An[6270,15,3505,3505,5385,0,0,0]An[6270,15,2256,2256,5334,0,0,0]An[6270,15,5732,5732,6045,0,0,0]An[6270,15,3010,3010,5364,0,0,0]An[6270,15,5240,5240,2329,0,0,0]=0An[6270,15,3010,3010,5364,0,0,0]An[6270,15,7334,7334,7038,0,0,0]Aj[6270,4,2793,6277,0,0,0,0]Ag[6270,6,6277,0,0,0,0,0]Ag[6270,5,5067,0,0,0,0,0]An[6270,15,3505,3505,5385,0,0,0]An[6270,15,6277,6277,2029,0,0,0]An[6270,15,3494,3494,7307,0,0,0]An[6270,15,2291,2291,4174,0,0,0]9An[6270,15,2235,2235,4653,0,0,0]An[6270,15,2236,2236,4184,0,0,0]An[6270,15,3505,3505,3333,0,0,0]An[6270,14,2794,2794,6636,0,0,0]An[6270,14,2627,2627,6519,0,0,0]An[6270,14,2793,2793,7445,0,0,0]An[6270,14,2236,2236,4179,0,0,0]An[6270,14,2787,2787,6037,0,0,0]Ag[6270,2,3398,0,0,0,0,0]Ag[6270,2,4589,0,0,0,0,0]Ag[6270,7,1723,0,0,0,0,0]An[6270,13,3514,3514,6508,0,0,0]An[6270,13,2256,2256,6062,0,0,0]Ba[6270,13,6271,6271,7037,1994,0,0]Ba[6270,13,1016,1016,4648,1994,0,0]Ba[6270,13,1016,1016,1013,1994,0,0]Ba[6270,13,1016,1016,4612,1994,0,0]Ba[6270,13,1016,1016,2877,1994,0,0]Ba[6270,13,7264,7264,8047,1994,0,0]Ba[6270,13,1732,1732,6376,1994,0,0]Ba[6270,13,3399,3399,7935,1994,0,0]Ba[6270,13,3399,3399,6331,1994,0,0]Ba[6270,13,3399,3399,7295,1994,0,0]Ba[6270,13,3399,3399,4609,1994,0,0]Ba[6270,13,3399,3399,6076,1994,0,0]Ba[6270,13,3399,3399,7031,1994,0,0]Ba[6270,13,3399,3399,7768,1994,0,0]Ba[6270,13,5756,5756,6654,1994,0,0]Ba[6270,13,5756,5756,7552,1994,0,0]Ba[6270,13,5756,5756,7551,1994,0,0]Ba[6270,13,2794,2794,6636,1994,0,0]Ba[6270,13,5293,5293,7317,1994,0,0]Ba[6270,13,5293,5293,7856,1994,0,0]Ba[6270,13,3046,3046,4649,1994,0,0]Ba[6270,13,3046,3046,3369,1994,0,0]Ba[6270,13,3046,3046,6319,1994,0,0]Ba[6270,13,3046,3046,6523,1994,0,0]Ba[6270,13,3046,3046,7057,1994,0,0]Ba[6270,13,3046,3046,6322,1994,0,0]Ba[6270,13,3046,3046,4613,1994,0,0]Ba[6270,13,2789,2789,6028,1994,0,0]Ba[6270,13,2789,2789,7039,1994,0,0]Ba[6270,13,6304,6304,7867,1994,0,0]Ba[6270,13,5654,5654,7292,1994,0,0]Ba[6270,13,5654,5654,6628,1994,0,0]Ba[6270,13,2902,2902,7289,1994,0,0]Ba[6270,13,2902,2902,7290,1994,0,0]Ba[6270,13,2902,2902,7291,1994,0,0]Ba[6270,13,2902,2902,7455,1994,0,0]Ba[6270,13,4218,4218,6637,1994,0,0]Ba[6270,13,5323,5323,3359,1994,0,0]Ba[6270,13,5323,5323,5342,1994,0,0]Ba[6270,13,5323,5323,4192,1994,0,0]Ba[6270,13,2997,2997,7442,1994,0,0]Ba[6270,13,6110,6110,7440,1994,0,0]Ba[6270,13,6110,6110,7987,1994,0,0]Ba[6270,13,6110,6110,7766,1994,0,0]Ba[6270,13,2235,2235,4182,1994,0,0]Ba[6270,13,2235,2235,4653,1994,0,0]Ba[6270,13,5732,5732,6045,1994,0,0]Ba[6270,13,3398,3398,6032,1994,0,0]Ba[6270,13,3398,3398,6033,1994,0,0]Ba[6270,13,2052,2052,5337,1994,0,0]Ba[6270,13,2052,2052,4651,1994,0,0]Ba[6270,13,2644,2644,6330,1994,0,0]Ba[6270,13,3010,3010,5364,1994,0,0]Ba[6270,13,3010,3010,6048,1994,0,0]Ba[6270,13,3010,3010,7450,1994,0,0]Ba[6270,13,2627,2627,6514,1994,0,0]Ba[6270,13,2627,2627,6042,1994,0,0]Ba[6270,13,2627,2627,5381,1994,0,0]Ba[6270,13,2627,2627,6519,1994,0,0]Ba[6270,13,2627,2627,4190,1994,0,0]Ba[6270,13,5683,5683,4630,1994,0,0]Ba[6270,13,5683,5683,4168,1994,0,0]Ba[6270,13,5683,5683,4628,1994,0,0]Ba[6270,13,2290,2290,6515,1994,0,0]Ba[6270,13,6452,6452,6053,1994,0,0]Ba[6270,13,3494,3494,7059,1994,0,0]Ba[6270,13,3494,3494,6640,1994,0,0]Ba[6270,13,3494,3494,7307,1994,0,0]Ba[6270,13,3514,3514,6508,1994,0,0]Ba[6270,13,2793,2793,7445,1994,0,0]Ba[6270,13,2280,2280,4166,1994,0,0]Ba[6270,13,2280,2280,5374,1994,0,0]Ba[6270,13,1718,1718,7327,1994,0,0]Ba[6270,13,1718,1718,4650,1994,0,0]Ba[6270,13,2898,2898,5371,1994,0,0]Ba[6270,13,6277,6277,2029,1994,0,0]Ba[6270,13,6277,6277,7060,1994,0,0]Ba[6270,13,6277,6277,6029,1994,0,0]Ba[6270,13,6277,6277,5369,1994,0,0]Ba[6270,13,2646,2646,7310,1994,0,0]Ba[6270,13,3034,3034,7048,1994,0,0]Ba[6270,13,2256,2256,4644,1994,0,0]Ba[6270,13,2256,2256,6062,1994,0,0]Ba[6270,13,2256,2256,5334,1994,0,0]Ba[6270,13,7334,7334,7038,1994,0,0]Ba[6270,13,2236,2236,6060,1994,0,0]Ba[6270,13,2236,2236,6373,1994,0,0]Ba[6270,13,2236,2236,4179,1994,0,0]Ba[6270,13,2236,2236,4184,1994,0,0]Ba[6270,13,2236,2236,5359,1994,0,0]Ba[6270,13,3392,3392,7034,1994,0,0]Ba[6270,13,3392,3392,7036,1994,0,0]Ba[6270,13,5240,5240,2329,1994,0,0]Ba[6270,13,5755,5755,6528,1994,0,0]Ba[6270,13,5755,5755,6653,1994,0,0]Ba[6270,13,5316,5316,6049,1994,0,0]Ba[6270,13,2291,2291,4196,1994,0,0]Ba[6270,13,2291,2291,2856,1994,0,0]Ba[6270,13,2291,2291,3368,1994,0,0]Ba[6270,13,2291,2291,4610,1994,0,0]Ba[6270,13,2291,2291,2336,1994,0,0]Ba[6270,13,2291,2291,4180,1994,0,0]Ba[6270,13,2291,2291,5361,1994,0,0]Ba[6270,13,2291,2291,5360,1994,0,0]Ba[6270,13,2291,2291,6073,1994,0,0]Ba[6270,13,2291,2291,7294,1994,0,0]Ba[6270,13,2291,2291,6631,1994,0,0]Ba[6270,13,2291,2291,5380,1994,0,0]Ba[6270,13,2291,2291,6371,1994,0,0]Ba[6270,13,2291,2291,6050,1994,0,0]Ba[6270,13,2291,2291,6516,1994,0,0]Ba[6270,13,2291,2291,2008,1994,0,0]Ba[6270,13,2291,2291,4174,1994,0,0]Ba[6270,13,2291,2291,6036,1994,0,0]Ba[6270,13,2291,2291,6367,1994,0,0]Ba[6270,13,2291,2291,7864,1994,0,0]Ba[6270,13,2787,2787,4176,1994,0,0]Ba[6270,13,2787,2787,5348,1994,0,0]Ba[6270,13,2787,2787,7045,1994,0,0]Ba[6270,13,2787,2787,7560,1994,0,0]Ba[6270,13,2787,2787,7559,1994,0,0]Ba[6270,13,7276,7276,4203,1994,0,0]Ba[6270,13,7276,7276,6051,1994,0,0]Ba[6270,13,7276,7276,4194,1994,0,0]Ba[6270,13,7276,7276,6369,1994,0,0]Ba[6270,13,7276,7276,6656,1994,0,0]Ba[6270,13,6601,6601,5375,1994,0,0]Ba[6270,13,3505,3505,5385,1994,0,0]Ba[6270,13,3505,3505,3333,1994,0,0]Ba[6270,13,3505,3505,6321,1994,0,0]Ba[6270,13,3505,3505,7065,1994,0,0]Ba[6270,13,3505,3505,6377,1994,0,0]Ba[6270,13,3505,3505,6633,1994,0,0]Ba[6270,13,5067,5067,2030,1994,0,0]Ba[6270,13,5067,5067,4642,1994,0,0]Ba[6270,13,2784,2784,6055,1994,0,0]Ba[6270,13,4589,4589,7571,1994,0,0]Ba[6270,13,2054,2054,5338,1994,0,0]An[6270,12,6277,6277,5355,0,0,0]Ba[6270,13,6431,6431,6041,5355,0,0]Ba[6270,13,5240,5240,4185,5355,0,0]An[6270,14,2291,2291,5361,0,0,0]An[6270,14,2787,2787,7560,0,0,0]An[6270,14,2787,2787,7051,0,0,0]Ag[6270,7,6278,0,0,0,0,0]An[6270,14,2902,2902,7289,0,0,0]Ak[6270,14,6329,0,2308,0,0,0]Ak[6270,14,5372,0,1998,0,0,0]An[6270,13,3505,3505,3333,0,0,0]An[6270,14,2236,2236,6373,0,0,0]876An[6270,14,6431,6431,6041,0,0,0]An[6270,14,2794,2794,5378,0,0,0]An[6270,14,2794,2794,7056,0,0,0]An[6270,14,2252,2252,7286,0,0,0]Ak[6270,14,7308,0,6056,0,0,0]An[6270,13,2256,2256,6062,0,0,0]An[6270,14,2256,2256,6062,0,0,0]An[6270,14,2291,2291,6073,0,0,0]An[6270,14,2787,2787,6336,0,0,0]An[6270,14,2787,2787,7865,0,0,0]Ak[6270,14,7302,0,2304,0,0,0];An[6270,14,7276,7276,6369,0,0,0]An[6270,13,3505,3505,7065,0,0,0]An[6270,14,5654,5654,6628,0,0,0]An[6270,14,2291,2291,6631,0,0,0]An[6270,14,2291,2291,6371,0,0,0]An[6270,13,1016,1016,5335,0,0,0]Ak[6270,14,5372,0,2857,0,0,0]Ak[6270,14,5372,0,2867,0,0,0]Ak[6270,14,5372,0,2331,0,0,0]Ak[6270,14,5372,0,2875,0,0,0]Ak[6270,14,5372,0,1998,0,0,0]An[6270,14,6277,6277,5369,0,0,0]Ag[6270,2,2640,0,0,0,0,0]Ag[6270,2,2793,0,0,0,0,0]An[6270,14,2794,2794,6054,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,14,2793,2793,7061,0,0,0]An[6270,14,6277,6277,7060,0,0,0]Ak[6270,14,7308,0,6378,0,0,0]An[6270,14,5240,5240,4185,0,0,0]4An[6270,12,2898,2898,6366,0,0,0]Ba[6270,13,2898,2898,5366,6366,0,0]An[6270,12,6277,6277,5355,0,0,0]Ba[6270,13,5708,5708,4639,6366,0,0]Ba[6270,13,6431,6431,6041,5355,0,0]Ba[6270,13,5240,5240,4185,5355,0,0]8An[6270,14,5240,5240,2358,0,0,0]Ag[6270,2,2794,0,0,0,0,0]<;>An[6270,13,5316,5316,6049,0,0,0]An[6270,13,5316,5316,6370,0,0,0]Ag[6270,7,6278,0,0,0,0,0]An[6270,14,2291,2291,6371,0,0,0]An[6270,14,2291,2291,7294,0,0,0]An[6270,13,7264,7264,6649,0,0,0]3An[6270,14,6278,6278,1007,0,0,0]An[6270,13,6616,6616,6375,0,0,0]An[6270,14,6277,6277,5369,0,0,0]An[6270,13,6601,6601,7778,0,0,0]An[6270,14,2794,2794,6054,0,0,0]An[6270,14,2902,2902,7289,0,0,0]An[6270,14,3010,3010,7450,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,12,2898,2898,6366,0,0,0]Ba[6270,13,2898,2898,5366,6366,0,0]An[6270,14,2898,2898,5366,0,0,0]Ak[6270,14,7308,0,6074,0,0,0]Ba[6270,13,5708,5708,4639,6366,0,0]An[6270,14,5708,5708,4639,0,0,0]An[6270,14,6496,6496,6079,0,0,0]Ag[6270,2,2793,0,0,0,0,0]An[6270,14,3399,3399,7295,0,0,0]An[6270,14,2794,2794,6636,0,0,0]An[6270,14,2794,2794,6638,0,0,0]An[6270,14,2793,2793,7445,0,0,0]An[6270,14,2236,2236,4179,0,0,0]An[6270,14,6278,6278,5356,0,0,0]An[6270,14,3399,3399,6331,0,0,0]An[6270,13,2054,2054,6530,0,0,0]Ag[6270,2,3438,0,0,0,0,0]Ag[6270,2,5290,0,0,0,0,0]Ag[6270,2,6271,0,0,0,0,0]An[6270,14,6277,6277,6029,0,0,0]An[6270,14,5242,5242,2361,0,0,0]An[6270,15,2235,2235,4182,0,0,0]An[6270,15,2291,2291,4174,0,0,0]An[6270,15,5683,5683,4168,0,0,0]An[6270,15,2794,2794,7055,0,0,0]An[6270,15,2794,2794,7303,0,0,0]An[6270,15,2236,2236,6060,0,0,0]An[6270,15,2644,2644,6330,0,0,0]An[6270,15,6277,6277,2029,0,0,0]7An[6270,15,3505,3505,7065,0,0,0]0An[6270,15,2291,2291,5380,0,0,0]An[6270,15,2794,2794,6078,0,0,0]An[6270,15,2794,2794,7325,0,0,0]An[6270,15,2794,2794,8039,0,0,0]An[6270,15,2794,2794,7869,0,0,0]An[6270,15,6601,6601,5375,0,0,0]An[6270,15,3505,3505,5385,0,0,0]Ag[6270,6,5240,0,0,0,0,0]:An[6270,15,2280,2280,5374,0,0,0]:An[6270,15,2235,2235,4182,0,0,0]Ag[6270,5,2291,0,0,0,0,0]An[6270,15,5732,5732,6045,0,0,0]>An[6270,15,6452,6452,6053,0,0,0]An[6270,15,2236,2236,5359,0,0,0]An[6270,15,2794,2794,7303,0,0,0]5An[6270,15,1718,1718,4650,0,0,0]6An[6270,15,5683,5683,4168,0,0,0]An[6270,15,2291,2291,4196,0,0,0]Ag[6270,5,5240,0,0,0,0,0]An[6270,15,5067,5067,4642,0,0,0]Ag[6270,5,5242,0,0,0,0,0]An[6270,15,3034,3034,7048,0,0,0]Ak[6270,14,7308,0,6502,0,0,0]An[6270,14,2787,2787,5348,0,0,0]An[6270,14,3399,3399,6076,0,0,0]An[6270,14,2794,2794,6636,0,0,0]An[6270,14,2794,2794,6638,0,0,0]An[6270,14,2793,2793,7445,0,0,0]An[6270,14,2236,2236,4179,0,0,0]An[6270,13,2291,2291,4174,0,0,0]Ag[6270,7,6496,0,0,0,0,0]Ba[6270,13,6271,6271,7037,2301,0,0]Ba[6270,13,1016,1016,5335,2301,0,0]Ba[6270,13,1016,1016,4648,2301,0,0]Ba[6270,13,1016,1016,1013,2301,0,0]Ba[6270,13,1016,1016,4612,2301,0,0]Ba[6270,13,1016,1016,2877,2301,0,0]Ba[6270,13,1016,1016,7062,2301,0,0]Ba[6270,13,1016,1016,8144,2301,0,0]Ba[6270,13,1016,1016,6038,2301,0,0]Ba[6270,13,7264,7264,6649,2301,0,0]Ba[6270,13,7264,7264,8047,2301,0,0]Ba[6270,13,1732,1732,6376,2301,0,0]Ba[6270,13,1017,1017,7437,2301,0,0]Ba[6270,13,1017,1017,7938,2301,0,0]Ba[6270,13,6111,6111,8145,2301,0,0]Ba[6270,13,3399,3399,7933,2301,0,0]Ba[6270,13,3399,3399,7563,2301,0,0]Ba[6270,13,3399,3399,7935,2301,0,0]Ba[6270,13,3399,3399,6331,2301,0,0]Ba[6270,13,3399,3399,7295,2301,0,0]Ba[6270,13,3399,3399,8290,2301,0,0]Ba[6270,13,3399,3399,7558,2301,0,0]Ba[6270,13,3399,3399,7769,2301,0,0]Ba[6270,13,3399,3399,4609,2301,0,0]Ba[6270,13,3399,3399,6076,2301,0,0]Ba[6270,13,3399,3399,7031,2301,0,0]Ba[6270,13,3399,3399,7768,2301,0,0]Ba[6270,13,5756,5756,6654,2301,0,0]Ba[6270,13,5756,5756,7552,2301,0,0]Ba[6270,13,5756,5756,7551,2301,0,0]Ba[6270,13,6453,6453,8147,2301,0,0]Ba[6270,13,6453,6453,7568,2301,0,0]Ba[6270,13,7500,7500,7992,2301,0,0]Ba[6270,13,2794,2794,7321,2301,0,0]Ba[6270,13,2794,2794,7454,2301,0,0]Ba[6270,13,2794,2794,8146,2301,0,0]Ba[6270,13,2794,2794,7784,2301,0,0]Ba[6270,13,2794,2794,7554,2301,0,0]Ba[6270,13,2794,2794,6054,2301,0,0]Ba[6270,13,2794,2794,6636,2301,0,0]Ba[6270,13,2794,2794,5378,2301,0,0]Ba[6270,13,2794,2794,6078,2301,0,0]Ba[6270,13,2794,2794,6638,2301,0,0]Ba[6270,13,2794,2794,7055,2301,0,0]Ba[6270,13,2794,2794,7056,2301,0,0]Ba[6270,13,2794,2794,7863,2301,0,0]Ba[6270,13,2794,2794,7304,2301,0,0]Ba[6270,13,2794,2794,7303,2301,0,0]Ba[6270,13,2794,2794,7325,2301,0,0]Ba[6270,13,2794,2794,8039,2301,0,0]Ba[6270,13,2794,2794,7869,2301,0,0]Ba[6270,13,2794,2794,7774,2301,0,0]Ba[6270,13,2794,2794,6368,2301,0,0]Ba[6270,13,2252,2252,7286,2301,0,0]Ba[6270,13,2252,2252,7550,2301,0,0]Ba[6270,13,2252,2252,7854,2301,0,0]Ba[6270,13,5293,5293,7312,2301,0,0]Ba[6270,13,5293,5293,8044,2301,0,0]Ba[6270,13,5293,5293,7317,2301,0,0]Ba[6270,13,5293,5293,7856,2301,0,0]Ba[6270,13,3046,3046,6627,2301,0,0]Ba[6270,13,3046,3046,7932,2301,0,0]Ba[6270,13,3046,3046,7871,2301,0,0]Ba[6270,13,3046,3046,4649,2301,0,0]Ba[6270,13,3046,3046,3369,2301,0,0]Ba[6270,13,3046,3046,6319,2301,0,0]Ba[6270,13,3046,3046,6523,2301,0,0]Ba[6270,13,3046,3046,7057,2301,0,0]Ba[6270,13,3046,3046,6322,2301,0,0]Ba[6270,13,3046,3046,4613,2301,0,0]Ba[6270,13,2789,2789,6028,2301,0,0]Ba[6270,13,2789,2789,7039,2301,0,0]Ba[6270,13,6304,6304,7867,2301,0,0]Ba[6270,13,5326,5326,6630,2301,0,0]Ba[6270,13,5654,5654,7292,2301,0,0]Ba[6270,13,5654,5654,6628,2301,0,0]Ba[6270,13,2902,2902,6512,2301,0,0]Ba[6270,13,2902,2902,7289,2301,0,0]Ba[6270,13,2902,2902,7290,2301,0,0]Ba[6270,13,2902,2902,7291,2301,0,0]Ba[6270,13,2902,2902,7455,2301,0,0]Ba[6270,13,4218,4218,6637,2301,0,0]Ba[6270,13,5323,5323,5377,2301,0,0]Ba[6270,13,5323,5323,3359,2301,0,0]Ba[6270,13,5323,5323,5342,2301,0,0]Ba[6270,13,5323,5323,4192,2301,0,0]Ba[6270,13,2997,2997,7442,2301,0,0]Ba[6270,13,3012,3012,5367,2301,0,0]Ba[6270,13,3498,3498,7779,2301,0,0]Ba[6270,13,6110,6110,7556,2301,0,0]Ba[6270,13,6110,6110,7440,2301,0,0]Ba[6270,13,6110,6110,7987,2301,0,0]Ba[6270,13,6110,6110,7766,2301,0,0]Ba[6270,13,5410,5410,6624,2301,0,0]Ba[6270,13,5410,5410,7040,2301,0,0]Ba[6270,13,5410,5410,6623,2301,0,0]Ba[6270,13,2235,2235,4182,2301,0,0]Ba[6270,13,2235,2235,4653,2301,0,0]Ba[6270,13,5732,5732,6045,2301,0,0]Ba[6270,13,3398,3398,6629,2301,0,0]Ba[6270,13,3398,3398,6032,2301,0,0]Ba[6270,13,3398,3398,6033,2301,0,0]Ba[6270,13,2052,2052,5337,2301,0,0]Ba[6270,13,2052,2052,4651,2301,0,0]Ba[6270,13,2644,2644,6330,2301,0,0]Ba[6270,13,3010,3010,5364,2301,0,0]Ba[6270,13,3010,3010,6048,2301,0,0]Ba[6270,13,3010,3010,7450,2301,0,0]Ba[6270,13,2841,2841,6506,2301,0,0]Ba[6270,13,2841,2841,6504,2301,0,0]Ba[6270,13,2841,2841,6328,2301,0,0]Ba[6270,13,2627,2627,6514,2301,0,0]Ba[6270,13,2627,2627,6042,2301,0,0]Ba[6270,13,2627,2627,5381,2301,0,0]Ba[6270,13,2627,2627,6519,2301,0,0]Ba[6270,13,2627,2627,4190,2301,0,0]Ba[6270,13,5683,5683,4630,2301,0,0]Ba[6270,13,5683,5683,4168,2301,0,0]Ba[6270,13,5683,5683,4628,2301,0,0]Ba[6270,13,2290,2290,6515,2301,0,0]Ba[6270,13,2290,2290,7776,2301,0,0]Ba[6270,13,2290,2290,7937,2301,0,0]Ba[6270,13,2290,2290,7991,2301,0,0]Ba[6270,13,6278,6278,7446,2301,0,0]Ba[6270,13,6278,6278,5356,2301,0,0]Ba[6270,13,6278,6278,3361,2301,0,0]Ba[6270,13,6278,6278,1007,2301,0,0]Ba[6270,13,6452,6452,8292,2301,0,0]Ba[6270,13,6452,6452,7567,2301,0,0]Ba[6270,13,6452,6452,6053,2301,0,0]Ba[6270,13,3494,3494,7059,2301,0,0]Ba[6270,13,3494,3494,6640,2301,0,0]Ba[6270,13,3494,3494,7307,2301,0,0]Ba[6270,13,6616,6616,4656,2301,0,0]Ba[6270,13,6616,6616,6375,2301,0,0]Ba[6270,13,3514,3514,6508,2301,0,0]Ba[6270,13,2793,2793,6069,2301,0,0]Ba[6270,13,2793,2793,7061,2301,0,0]Ba[6270,13,2793,2793,7445,2301,0,0]Ba[6270,13,2280,2280,4166,2301,0,0]Ba[6270,13,2280,2280,5374,2301,0,0]Ba[6270,13,1718,1718,7327,2301,0,0]Ba[6270,13,1718,1718,4650,2301,0,0]Ba[6270,13,2898,2898,5366,2301,0,0]Ba[6270,13,2898,2898,5371,2301,0,0]Ba[6270,13,6277,6277,2029,2301,0,0]Ba[6270,13,6277,6277,7060,2301,0,0]Ba[6270,13,6277,6277,6029,2301,0,0]Ba[6270,13,6277,6277,5369,2301,0,0]Ba[6270,13,2646,2646,7323,2301,0,0]Ba[6270,13,2646,2646,7310,2301,0,0]Ba[6270,13,5708,5708,4639,2301,0,0]Ba[6270,13,5708,5708,6335,2301,0,0]Ba[6270,13,6496,6496,6077,2301,0,0]Ba[6270,13,6496,6496,6079,2301,0,0]Ba[6270,13,3034,3034,7562,2301,0,0]Ba[6270,13,3034,3034,7048,2301,0,0]Ba[6270,13,2256,2256,4644,2301,0,0]Ba[6270,13,2256,2256,6062,2301,0,0]Ba[6270,13,2256,2256,5334,2301,0,0]Ba[6270,13,7334,7334,7038,2301,0,0]Ba[6270,13,2236,2236,6060,2301,0,0]Ba[6270,13,2236,2236,6373,2301,0,0]Ba[6270,13,2236,2236,4179,2301,0,0]Ba[6270,13,2236,2236,4184,2301,0,0]Ba[6270,13,2236,2236,5359,2301,0,0]Ba[6270,13,3392,3392,7034,2301,0,0]Ba[6270,13,3392,3392,7036,2301,0,0]Ba[6270,13,6431,6431,6041,2301,0,0]Ba[6270,13,5240,5240,4185,2301,0,0]Ba[6270,13,5240,5240,2358,2301,0,0]Ba[6270,13,5240,5240,2329,2301,0,0]Ba[6270,13,5755,5755,6528,2301,0,0]Ba[6270,13,5755,5755,6653,2301,0,0]Ba[6270,13,5316,5316,6049,2301,0,0]Ba[6270,13,5316,5316,6370,2301,0,0]An[6270,13,5242,5242,4,2301,0,0]Ba[6270,13,5242,5242,2361,2301,0,0]Ba[6270,13,5242,5242,7049,2301,0,0]Ba[6270,13,3015,3015,7301,2301,0,0]Ba[6270,13,3015,3015,4614,2301,0,0]Ba[6270,13,2291,2291,4196,2301,0,0]Ba[6270,13,2291,2291,2856,2301,0,0]Ba[6270,13,2291,2291,3368,2301,0,0]Ba[6270,13,2291,2291,4610,2301,0,0]Ba[6270,13,2291,2291,2336,2301,0,0]Ba[6270,13,2291,2291,4180,2301,0,0]Ba[6270,13,2291,2291,5361,2301,0,0]Ba[6270,13,2291,2291,5360,2301,0,0]Ba[6270,13,2291,2291,6073,2301,0,0]Ba[6270,13,2291,2291,7294,2301,0,0]Ba[6270,13,2291,2291,6631,2301,0,0]Ba[6270,13,2291,2291,5380,2301,0,0]Ba[6270,13,2291,2291,6371,2301,0,0]Ba[6270,13,2291,2291,6050,2301,0,0]Ba[6270,13,2291,2291,6516,2301,0,0]Ba[6270,13,2291,2291,2008,2301,0,0]Ba[6270,13,2291,2291,4174,2301,0,0]Ba[6270,13,2291,2291,6036,2301,0,0]Ba[6270,13,2291,2291,6367,2301,0,0]Ba[6270,13,2291,2291,7864,2301,0,0]Ba[6270,13,2787,2787,7053,2301,0,0]Ba[6270,13,2787,2787,4176,2301,0,0]Ba[6270,13,2787,2787,5348,2301,0,0]Ba[6270,13,2787,2787,6336,2301,0,0]Ba[6270,13,2787,2787,7931,2301,0,0]Ba[6270,13,2787,2787,7865,2301,0,0]Ba[6270,13,2787,2787,7772,2301,0,0]Ba[6270,13,2787,2787,6037,2301,0,0]Ba[6270,13,2787,2787,7045,2301,0,0]Ba[6270,13,2787,2787,4173,2301,0,0]Ba[6270,13,2787,2787,7560,2301,0,0]Ba[6270,13,2787,2787,7559,2301,0,0]Ba[6270,13,2787,2787,7564,2301,0,0]Ba[6270,13,2787,2787,6327,2301,0,0]Ba[6270,13,2787,2787,7293,2301,0,0]Ba[6270,13,2787,2787,7457,2301,0,0]Ba[6270,13,2787,2787,6657,2301,0,0]Ba[6270,13,2787,2787,7775,2301,0,0]Ba[6270,13,2787,2787,7050,2301,0,0]Ba[6270,13,2787,2787,7051,2301,0,0]Ba[6270,13,2658,2658,7044,2301,0,0]Ba[6270,13,7276,7276,4203,2301,0,0]Ba[6270,13,7276,7276,6051,2301,0,0]Ba[6270,13,7276,7276,4194,2301,0,0]Ba[6270,13,7276,7276,6369,2301,0,0]Ba[6270,13,7276,7276,6656,2301,0,0]Ba[6270,13,6601,6601,7778,2301,0,0]Ba[6270,13,6601,6601,5375,2301,0,0]Ba[6270,13,3505,3505,5385,2301,0,0]Ba[6270,13,3505,3505,3333,2301,0,0]Ba[6270,13,3505,3505,6321,2301,0,0]Ba[6270,13,3505,3505,7065,2301,0,0]Ba[6270,13,3505,3505,6377,2301,0,0]Ba[6270,13,3505,3505,6633,2301,0,0]Ba[6270,13,3045,3045,3325,2301,0,0]Ba[6270,13,5067,5067,5383,2301,0,0]Ba[6270,13,5067,5067,6645,2301,0,0]Ba[6270,13,5067,5067,2030,2301,0,0]Ba[6270,13,5067,5067,4642,2301,0,0]Ba[6270,13,2784,2784,6055,2301,0,0]Ba[6270,13,4589,4589,7571,2301,0,0]Ba[6270,13,2054,2054,6530,2301,0,0]Ba[6270,13,2054,2054,5338,2301,0,0]Ba[6270,13,2842,2842,6659,2301,0,0]Ba[6270,13,2842,2842,7939,2301,0,0]An[6270,13,5755,5755,6653,0,0,0]An[6270,14,2841,2841,6506,0,0,0]An[6270,14,2627,2627,5381,0,0,0]An[6270,14,5683,5683,4628,0,0,0]An[6270,14,2842,2842,6659,0,0,0]Ag[6270,2,3438,0,0,0,0,0]An[6270,14,5756,5756,6654,0,0,0]An[6270,14,2052,2052,4651,0,0,0]An[6270,14,2627,2627,6514,0,0,0]5An[6270,14,2054,2054,6530,0,0,0]An[6270,13,5293,5293,7312,0,0,0]An[6270,13,2627,2627,6514,0,0,0]Ag[6270,7,6496,0,0,0,0,0]An[6270,14,7276,7276,6369,0,0,0]An[6270,13,3505,3505,7065,0,0,0]Ag[6270,2,3395,0,0,0,0,0]Ag[6270,2,6271,0,0,0,0,0]An[6270,12,6452,6452,7569,0,0,0]Ba[6270,13,6452,6452,8292,7569,0,0]Ag[6270,2,3398,0,0,0,0,0]Ag[6270,2,2793,0,0,0,0,0]An[6270,14,2902,2902,7455,0,0,0]An[6270,14,2291,2291,6631,0,0,0]An[6270,14,2787,2787,6657,0,0,0]An[6270,14,3046,3046,4613,0,0,0]An[6270,14,3015,3015,4614,0,0,0]An[6270,14,2291,2291,4180,0,0,0]4An[6270,14,2787,2787,6037,0,0,0]Ag[6270,7,6278,0,0,0,0,0]0An[6270,14,5242,5242,2361,0,0,0]Ag[6270,2,5290,0,0,0,0,0]Ba[6270,13,1016,1016,4648,1012,0,0]Ba[6270,13,1016,1016,1013,1012,0,0]Ba[6270,13,1016,1016,4612,1012,0,0]Ba[6270,13,1016,1016,2877,1012,0,0]Ba[6270,13,1016,1016,7062,1012,0,0]Ba[6270,13,1016,1016,8144,1012,0,0]Ba[6270,13,1016,1016,6038,1012,0,0]Ba[6270,13,1732,1732,6376,1012,0,0]Ba[6270,13,3399,3399,7935,1012,0,0]Ba[6270,13,3399,3399,6331,1012,0,0]Ba[6270,13,3399,3399,7295,1012,0,0]Ba[6270,13,3399,3399,8290,1012,0,0]Ba[6270,13,3399,3399,7558,1012,0,0]Ba[6270,13,3399,3399,7769,1012,0,0]Ba[6270,13,3399,3399,6076,1012,0,0]Ba[6270,13,3399,3399,7031,1012,0,0]Ba[6270,13,3399,3399,7768,1012,0,0]Ba[6270,13,5756,5756,6654,1012,0,0]Ba[6270,13,5756,5756,7552,1012,0,0]Ba[6270,13,5756,5756,7551,1012,0,0]Ba[6270,13,6453,6453,7568,1012,0,0]Ba[6270,13,2794,2794,6054,1012,0,0]Ba[6270,13,2794,2794,6636,1012,0,0]Ba[6270,13,2794,2794,5378,1012,0,0]Ba[6270,13,2794,2794,6078,1012,0,0]Ba[6270,13,2794,2794,6638,1012,0,0]Ba[6270,13,2794,2794,7055,1012,0,0]Ba[6270,13,2794,2794,7056,1012,0,0]Ba[6270,13,2794,2794,7863,1012,0,0]Ba[6270,13,2794,2794,7304,1012,0,0]Ba[6270,13,2794,2794,7303,1012,0,0]Ba[6270,13,2794,2794,7325,1012,0,0]Ba[6270,13,2794,2794,8039,1012,0,0]Ba[6270,13,2794,2794,7869,1012,0,0]Ba[6270,13,2794,2794,7774,1012,0,0]Ba[6270,13,2794,2794,6368,1012,0,0]Ba[6270,13,2252,2252,7286,1012,0,0]Ba[6270,13,2252,2252,7550,1012,0,0]Ba[6270,13,2252,2252,7854,1012,0,0]Ba[6270,13,5293,5293,7317,1012,0,0]Ba[6270,13,5293,5293,7856,1012,0,0]Ba[6270,13,3046,3046,4649,1012,0,0]Ba[6270,13,3046,3046,3369,1012,0,0]Ba[6270,13,3046,3046,6319,1012,0,0]Ba[6270,13,3046,3046,6523,1012,0,0]Ba[6270,13,3046,3046,7057,1012,0,0]Ba[6270,13,3046,3046,6322,1012,0,0]Ba[6270,13,3046,3046,4613,1012,0,0]Ba[6270,13,2789,2789,6028,1012,0,0]Ba[6270,13,2789,2789,7039,1012,0,0]Ba[6270,13,6304,6304,7867,1012,0,0]Ba[6270,13,5326,5326,6630,1012,0,0]Ba[6270,13,5654,5654,7292,1012,0,0]Ba[6270,13,5654,5654,6628,1012,0,0]Ba[6270,13,2902,2902,7289,1012,0,0]Ba[6270,13,2902,2902,7290,1012,0,0]Ba[6270,13,2902,2902,7291,1012,0,0]Ba[6270,13,2902,2902,7455,1012,0,0]Ba[6270,13,5323,5323,5342,1012,0,0]Ba[6270,13,2997,2997,7442,1012,0,0]Ba[6270,13,3012,3012,5367,1012,0,0]Ba[6270,13,6110,6110,7440,1012,0,0]Ba[6270,13,6110,6110,7987,1012,0,0]Ba[6270,13,2235,2235,4182,1012,0,0]Ba[6270,13,2235,2235,4653,1012,0,0]Ba[6270,13,3398,3398,6032,1012,0,0]Ba[6270,13,2052,2052,5337,1012,0,0]Ba[6270,13,3010,3010,5364,1012,0,0]Ba[6270,13,3010,3010,6048,1012,0,0]Ba[6270,13,2627,2627,6042,1012,0,0]Ba[6270,13,2627,2627,5381,1012,0,0]Ba[6270,13,2627,2627,6519,1012,0,0]Ba[6270,13,2627,2627,4190,1012,0,0]Ba[6270,13,6452,6452,6053,1012,0,0]Ba[6270,13,2280,2280,4166,1012,0,0]Ba[6270,13,2280,2280,5374,1012,0,0]Ba[6270,13,2646,2646,7310,1012,0,0]Ba[6270,13,3034,3034,7048,1012,0,0]Ba[6270,13,2256,2256,4644,1012,0,0]Ba[6270,13,2256,2256,6062,1012,0,0]Ba[6270,13,7334,7334,7038,1012,0,0]Ba[6270,13,2236,2236,6060,1012,0,0]Ba[6270,13,2236,2236,6373,1012,0,0]Ba[6270,13,2236,2236,4179,1012,0,0]Ba[6270,13,2236,2236,4184,1012,0,0]Ba[6270,13,2236,2236,5359,1012,0,0]Ba[6270,13,3392,3392,7034,1012,0,0]Ba[6270,13,3392,3392,7036,1012,0,0]Ba[6270,13,5755,5755,6528,1012,0,0]Ba[6270,13,5755,5755,6653,1012,0,0]Ba[6270,13,2291,2291,4196,1012,0,0]Ba[6270,13,2291,2291,2856,1012,0,0]Ba[6270,13,2291,2291,3368,1012,0,0]Ba[6270,13,2291,2291,4610,1012,0,0]Ba[6270,13,2291,2291,2336,1012,0,0]Ba[6270,13,2291,2291,4180,1012,0,0]Ba[6270,13,2291,2291,5361,1012,0,0]Ba[6270,13,2291,2291,5360,1012,0,0]Ba[6270,13,2291,2291,6073,1012,0,0]Ba[6270,13,2291,2291,7294,1012,0,0]Ba[6270,13,2291,2291,6631,1012,0,0]Ba[6270,13,2291,2291,5380,1012,0,0]Ba[6270,13,2291,2291,6371,1012,0,0]Ba[6270,13,2291,2291,6050,1012,0,0]Ba[6270,13,2291,2291,6516,1012,0,0]Ba[6270,13,2291,2291,2008,1012,0,0]Ba[6270,13,2291,2291,4174,1012,0,0]Ba[6270,13,2291,2291,6036,1012,0,0]Ba[6270,13,2291,2291,6367,1012,0,0]Ba[6270,13,2291,2291,7864,1012,0,0]Ba[6270,13,2787,2787,4176,1012,0,0]Ba[6270,13,7276,7276,4203,1012,0,0]Ba[6270,13,7276,7276,6051,1012,0,0]Ba[6270,13,7276,7276,4194,1012,0,0]Ba[6270,13,7276,7276,6369,1012,0,0]Ba[6270,13,7276,7276,6656,1012,0,0]Ba[6270,13,3505,3505,5385,1012,0,0]Ba[6270,13,3505,3505,3333,1012,0,0]Ba[6270,13,3505,3505,6321,1012,0,0]Ba[6270,13,3505,3505,7065,1012,0,0]Ba[6270,13,3505,3505,6377,1012,0,0]Ba[6270,13,3505,3505,6633,1012,0,0]Ba[6270,13,4589,4589,7571,1012,0,0]An[6270,13,1016,1016,5335,0,0,0]An[6270,14,2787,2787,6657,0,0,0]Ag[6270,2,3045,0,0,0,0,0]An[6270,14,2658,2658,7044,0,0,0]Ag[6270,2,5290,0,0,0,0,0]An[6270,14,5067,5067,2030,0,0,0]Ag[6270,2,3046,0,0,0,0,0]An[6270,14,2291,2291,4180,0,0,0]An[6270,14,2787,2787,6037,0,0,0]Ag[6270,2,6271,0,0,0,0,0]Ag[6270,2,3395,0,0,0,0,0]An[6270,14,5323,5323,5342,0,0,0]Ak[6270,14,8046,0,5362,0,0,0]An[6270,14,5323,5323,5377,0,0,0]An[6270,13,5316,5316,6049,0,0,0]Ag[6270,7,5655,0,0,0,0,0]Ag[6270,2,2794,0,0,0,0,0]An[6270,13,2256,2256,6062,0,0,0]An[6270,14,6496,6496,6077,0,0,0]An[6270,13,5242,5242,7049,0,0,0]An[6270,14,2794,2794,6054,0,0,0]An[6270,14,2794,2794,5378,0,0,0]Ak[6270,14,7308,0,6056,0,0,0]An[6270,14,2256,2256,6062,0,0,0]An[6270,13,3494,3494,7059,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,14,5293,5293,7317,0,0,0]Ag[6270,2,3045,0,0,0,0,0]An[6270,12,5290,5290,6635,0,0,0]Ba[6270,13,3015,3015,7301,6635,0,0]Ba[6270,13,2787,2787,7053,6635,0,0]Ba[6270,13,2658,2658,7044,6635,0,0]An[6270,14,5240,5240,4185,0,0,0]Ag[6270,7,1723,0,0,0,0,0]An[6270,14,2236,2236,4179,0,0,0]An[6270,14,1732,1732,6376,0,0,0]An[6270,14,6110,6110,7766,0,0,0]An[6270,14,3398,3398,6032,0,0,0]Ak[6270,14,5372,0,2875,0,0,0]An[6270,14,5242,5242,7049,0,0,0]Ag[6270,2,2794,0,0,0,0,0]An[6270,14,2794,2794,6054,0,0,0]An[6270,13,2793,2793,7061,0,0,0]An[6270,12,2898,2898,6366,0,0,0]Ba[6270,13,2898,2898,5366,6366,0,0]An[6270,14,2898,2898,5366,0,0,0]An[6270,14,6277,6277,7060,0,0,0]Ak[6270,14,7308,0,6378,0,0,0]Ba[6270,13,5708,5708,4639,6366,0,0]An[6270,14,5708,5708,6335,0,0,0]An[6270,14,5240,5240,4185,0,0,0]An[6270,14,6496,6496,6079,0,0,0]Ag[6270,2,3398,0,0,0,0,0]Ag[6270,2,4589,0,0,0,0,0]An[6270,15,1718,1718,4650,0,0,0]An[6270,15,2235,2235,4182,0,0,0]0Ah[6270,18,5242,0,0,0,0,0]An[6270,15,3505,3505,7065,0,0,0]An[6270,15,5410,5410,7040,0,0,0]An[6270,15,5410,5410,6623,0,0,0]Ag[6270,5,2291,0,0,0,0,0]An[6270,15,2280,2280,5374,0,0,0]An[6270,15,2841,2841,6328,0,0,0]An[6270,15,5683,5683,4168,0,0,0]An[6270,15,2291,2291,4196,0,0,0]An[6270,15,2842,2842,7939,0,0,0]1An[6270,15,6277,6277,2029,0,0,0]50An[6270,15,2291,2291,4174,0,0,0]6Ag[6270,5,1016,0,0,0,0,0]An[6270,15,2644,2644,6330,0,0,0]7An[6270,15,3494,3494,7307,0,0,0]An[6270,15,3010,3010,5364,0,0,0]An[6270,14,6616,6616,6375,0,0,0]An[6270,14,3046,3046,4613,0,0,0]An[6270,14,3012,3012,5367,0,0,0]An[6270,14,2627,2627,6519,0,0,0]An[6270,14,2236,2236,4179,0,0,0]An[6270,14,3015,3015,4614,0,0,0]An[6270,14,2291,2291,4180,0,0,0]An[6270,14,2787,2787,6037,0,0,0]An[6270,14,6271,6271,7037,0,0,0]An[6270,14,2794,2794,6054,0,0,0]An[6270,14,2793,2793,7061,0,0,0]Ag[6270,2,2793,0,0,0,0,0]An[6270,14,2291,2291,5360,0,0,0]An[6270,14,2787,2787,7559,0,0,0]An[6270,14,2787,2787,7050,0,0,0]Ag[6270,2,3395,0,0,0,0,0]Ba[6270,13,5732,5732,6045,4617,0,0]An[6270,14,2794,2794,7056,0,0,0]An[6270,14,2794,2794,7304,0,0,0]Ak[6270,14,7305,0,2034,0,0,0]An[6270,13,2256,2256,6062,0,0,0]An[6270,14,2256,2256,6062,0,0,0]An[6270,14,2787,2787,7457,0,0,0]An[6270,13,3034,3034,7562,0,0,0]An[6270,14,3034,3034,7562,0,0,0]4An[6270,13,5410,5410,6624,0,0,0]Ba[6270,13,5323,5323,5342,2847,0,0]Ba[6270,13,6110,6110,7440,2847,0,0]Ba[6270,13,5410,5410,6624,2847,0,0]Ba[6270,13,2235,2235,4182,2847,0,0]Ba[6270,13,2235,2235,4653,2847,0,0]Ba[6270,13,2052,2052,5337,2847,0,0]Ba[6270,13,3010,3010,6048,2847,0,0]Ba[6270,13,3010,3010,7450,2847,0,0]Ba[6270,13,5683,5683,4630,2847,0,0]Ba[6270,13,6616,6616,4656,2847,0,0]Ba[6270,13,3514,3514,6508,2847,0,0]Ba[6270,13,2793,2793,7061,2847,0,0]Ba[6270,13,2280,2280,5374,2847,0,0]Ba[6270,13,2898,2898,5366,2847,0,0]Ba[6270,13,6277,6277,7060,2847,0,0]Ba[6270,13,3034,3034,7562,2847,0,0]Ba[6270,13,2256,2256,4644,2847,0,0]Ba[6270,13,2256,2256,6062,2847,0,0]Ba[6270,13,2236,2236,6060,2847,0,0]Ba[6270,13,3392,3392,7036,2847,0,0]Ba[6270,13,5240,5240,2358,2847,0,0]Ba[6270,13,5755,5755,6653,2847,0,0]Ba[6270,13,5316,5316,6049,2847,0,0]Ba[6270,13,5316,5316,6370,2847,0,0]Ba[6270,13,2291,2291,3368,2847,0,0]Ba[6270,13,2291,2291,4180,2847,0,0]Ba[6270,13,2291,2291,6631,2847,0,0]Ba[6270,13,2291,2291,6371,2847,0,0]Ba[6270,13,2291,2291,6036,2847,0,0]Ba[6270,13,3505,3505,5385,2847,0,0]Ba[6270,13,3505,3505,6321,2847,0,0]Ba[6270,13,3505,3505,7065,2847,0,0]Ba[6270,13,3505,3505,6377,2847,0,0]Ba[6270,13,3505,3505,6633,2847,0,0]Ba[6270,13,2054,2054,5338,2847,0,0]Ba[6270,13,2842,2842,6659,2847,0,0]An[6270,13,5293,5293,7312,0,0,0]An[6270,13,5242,5242,7049,0,0,0]An[6270,12,6452,6452,7569,0,0,0]Ba[6270,13,6452,6452,8292,7569,0,0]An[6270,13,3034,3034,7562,0,0,0]Ag[6270,7,6281,0,0,0,0,0]An[6270,14,3392,3392,7036,0,0,0]An[6270,14,2054,2054,5338,0,0,0]An[6270,13,5410,5410,6624,0,0,0]An[6270,12,5067,5067,6626,0,0,0]An[6270,13,5067,5067,5383,0,0,0]Ba[6270,13,5067,5067,6645,6626,0,0];An[6270,14,2256,2256,6062,0,0,0]An[6270,14,2787,2787,4176,0,0,0]Ag[6270,7,6496,0,0,0,0,0]An[6270,14,1732,1732,6376,0,0,0]An[6270,14,3399,3399,8290,0,0,0]An[6270,14,3399,3399,7558,0,0,0]An[6270,14,3399,3399,7769,0,0,0]An[6270,14,3046,3046,7057,0,0,0]An[6270,14,2789,2789,7039,0,0,0]An[6270,14,5326,5326,6630,0,0,0]An[6270,14,4218,4218,6637,0,0,0]An[6270,14,5323,5323,4192,0,0,0]Ak[6270,14,3358,0,6363,0,0,0]Ak[6270,14,3358,0,6641,0,0,0]Ak[6270,14,3358,0,4634,0,0,0]Ak[6270,14,3358,0,6043,0,0,0]Ak[6270,14,3358,0,3335,0,0,0]An[6270,14,3398,3398,6032,0,0,0]Ak[6270,14,6329,0,2339,0,0,0]Ak[6270,14,6329,0,2878,0,0,0]Ak[6270,14,6329,0,3354,0,0,0]Ak[6270,14,6329,0,4160,0,0,0]Ak[6270,14,6329,0,2308,0,0,0]Ak[6270,14,6329,0,2330,0,0,0]Ak[6270,14,6329,0,4157,0,0,0]An[6270,14,2290,2290,7776,0,0,0]An[6270,14,2290,2290,7937,0,0,0]An[6270,14,2290,2290,7991,0,0,0]Ak[6270,14,7305,0,2034,0,0,0]Ak[6270,14,7305,0,3355,0,0,0]Ak[6270,14,7305,0,2880,0,0,0]Ak[6270,14,7305,0,4635,0,0,0]An[6270,14,2784,2784,6055,0,0,0]Ag[6270,2,3395,0,0,0,0,0]An[6270,14,5654,5654,6628,0,0,0]An[6270,14,6271,6271,7037,0,0,0]Ak[6270,14,8046,0,6046,0,0,0]An[6270,14,2898,2898,5371,0,0,0]Ak[6270,14,7308,0,1999,0,0,0]Ak[6270,14,4195,0,2859,0,0,0]Ak[6270,14,4195,0,6499,0,0,0]Ak[6270,14,4195,0,3341,0,0,0]Ak[6270,14,4195,0,2355,0,0,0]Ak[6270,14,4195,0,3323,0,0,0]An[6270,13,3045,3045,3325,0,0,0]Ag[6270,2,3398,0,0,0,0,0]Ba[\"{c{{ABEd{CGAn}}}BOAl}\",[\"__D\"]]?Ba[\"{c{{ABEd{CGB`}}}BOAl}\",[\"__D\"]]Cg[\"{{{BAIf{{B@Fn{c}}}}OOb}{{ABEd{e}}}AFGd{}}\",[\"R\",\"T\"]]0Ba[\"{c{{ABEd{CMFn}}}BOAl}\",[\"__D\"]]1Ba[\"{c{{ABEd{CIA`}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{CI@n}}}BOAl}\",[\"__D\"]]33Ba[\"{c{{ABEd{CHNl}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{CJOl}}}BOAl}\",[\"__D\"]]55Ba[\"{c{{ABEd{CMEl}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{CIBj}}}BOAl}\",[\"__D\"]]7Ba[\"{c{{ABEd{CMF`}}}BOAl}\",[\"__D\"]]8Ba[\"{c{{ABEd{BDEb}}}BOAl}\",[\"__D\"]]9Ba[\"{c{{ABEd{AJEb}}}BOAl}\",[\"__D\"]]::Ba[\"{c{{ABEd{CAEn}}}BOAl}\",[\"__D\"]];Ba[\"{c{{ABEd{CBOf}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{BDEj}}}BOAl}\",[\"__D\"]]??Ba[\"{c{{ABEd{BOB`}}}BOAl}\",[\"__D\"]]Cg[\"{{{BAIf{{B@Fn{c}}}}OOb}{{ABEd{e}}}AFGd{}}\",[\"R\",\"T\"]]Ba[\"{c{{ABEd{BIKb}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{CAGd}}}BOAl}\",[\"__D\"]]22Ba[\"{c{{ABEd{BINh}}}BOAl}\",[\"__D\"]]3Ba[\"{c{{ABEd{BOD`}}}BOAl}\",[\"__D\"]]4Ba[\"{c{{ABEd{BOCd}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{BJ@j}}}BOAl}\",[\"__D\"]]6Ba[\"{c{{ABEd{CBNn}}}BOAl}\",[\"__D\"]]7Ba[\"{c{{ABEd{B@Kl}}}BOAl}\",[\"__D\"]]8Ba[\"{c{{ABEd{BODj}}}BOAl}\",[\"__D\"]]99Ba[\"{c{{ABEd{B@Hl}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{BIOl}}}BOAl}\",[\"__D\"]];;Ba[\"{c{{ABEd{CIAl}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{CGA`}}}BOAl}\",[\"__D\"]]==Ba[\"{c{{ABEd{BDDh}}}BOAl}\",[\"__D\"]]>Ba[\"{c{{ABEd{BOEl}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{CFOl}}}BOAl}\",[\"__D\"]]Cg[\"{{{BAIf{{B@Fn{c}}}}OOb}{{ABEd{e}}}AFGd{}}\",[\"R\",\"T\"]]Ba[\"{c{{ABEd{BOEh}}}BOAl}\",[\"__D\"]]11Ba[\"{c{{ABEd{CALj}}}BOAl}\",[\"__D\"]]2Ba[\"{c{{ABEd{B@Jf}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{B@K`}}}BOAl}\",[\"__D\"]]44Ba[\"{c{{ABEd{BIMn}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{CFOh}}}BOAl}\",[\"__D\"]]6Ba[\"{c{{ABEd{CC@`}}}BOAl}\",[\"__D\"]]77Ba[\"{c{{ABEd{CCOj}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{B@Lh}}}BOAl}\",[\"__D\"]]9Ba[\"{c{{ABEd{AFE`}}}BOAl}\",[\"__D\"]]::Ba[\"{c{{ABEd{AJE`}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{BD@d}}}BOAl}\",[\"__D\"]]<Ba[\"{c{{ABEd{CMOf}}}BOAl}\",[\"__D\"]]?Ba[\"{c{{ABEd{CMGb}}}BOAl}\",[\"__D\"]]Cg[\"{{{BAIf{{B@Fn{c}}}}OOb}{{ABEd{e}}}AFGd{}}\",[\"R\",\"T\"]]Ba[\"{c{{ABEd{CLKh}}}BOAl}\",[\"__D\"]]1Ba[\"{c{{ABEd{BOBj}}}BOAl}\",[\"__D\"]]2Ba[\"{c{{ABEd{CG@j}}}BOAl}\",[\"__D\"]]33Ba[\"{c{{ABEd{B@Ij}}}BOAl}\",[\"__D\"]]4Ba[\"{c{{ABEd{CKA`}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{CK@n}}}BOAl}\",[\"__D\"]]66Ba[\"{c{{ABEd{CLKn}}}BOAl}\",[\"__D\"]]7Ba[\"{c{{ABEd{CGAd}}}BOAl}\",[\"__D\"]]8Ba[\"{c{{ABEd{CGAf}}}BOAl}\",[\"__D\"]]9Ba[\"{c{{ABEd{B@Mf}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{BODf}}}BOAl}\",[\"__D\"]];Ba[\"{c{{ABEd{B@Ld}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{BJAb}}}BOAl}\",[\"__D\"]]??Ba[\"{c{{ABEd{AJ@j}}}BOAl}\",[\"__D\"]]Cg[\"{{{BAIf{{B@Fn{c}}}}OOb}{{ABEd{e}}}AFGd{}}\",[\"R\",\"T\"]]Ba[\"{c{{ABEd{CAFb}}}BOAl}\",[\"__D\"]]1Ba[\"{c{{ABEd{CGCb}}}BOAl}\",[\"__D\"]]2Ba[\"{c{{ABEd{CAMb}}}BOAl}\",[\"__D\"]]3Ba[\"{c{{ABEd{CCMb}}}BOAl}\",[\"__D\"]]4Ba[\"{c{{ABEd{BODn}}}BOAl}\",[\"__D\"]]Ba[\"{c{{ABEd{CKBf}}}BOAl}\",[\"__D\"]]6Aa[\"{BJ@jABBn}\",[]]A`[\"{BODdHNf}\",[]]A`[\"{ABDfFDf}\",[]]A`[\"{AFGlFDf}\",[]]A`[\"{AJCdFDf}\",[]]A`[\"{B@H`FDf}\",[]]A`[\"{AB@hFDf}\",[]]A`[\"{ABCdFDf}\",[]]A`[\"{B@GjFDf}\",[]]Am[\"{{{BAIf{FEbCGAb}}}MHl}\",[]]A`[\"{CJBjFDf}\",[]]Aa[\"{B@JhABBn}\",[]]A`[\"{DLfABFn}\",[]]A`[\"{BOGjBJl}\",[]]Ca[\"{{{BAIf{CGBf}}{BAIf{CIAf}}}{{ABBn{ABFn}}}}\",[]]Be[\"{{{BAIf{BIJn}}}{{ABBn{AFGj}}}}\",[]]Cl[\"{{{BAIf{CBNd}}{BAIf{GIh}}}{{ABEd{{ABBn{BJ@j}}ABFn}}}}\",[]]Aj[\"{{{BAIf{CI@f}}}HNf}\",[]]Aj[\"{{{BAIf{B@Jh}}}HNf}\",[]]Aj[\"{{{BAIf{BOGb}}}HNf}\",[]]Aj[\"{{{BAIf{BODd}}}HNf}\",[]]Aj[\"{{{BAIf{CMG`}}}HNf}\",[]]A`[\"{BIJnBJl}\",[]]A`[\"{CK@hBJl}\",[]]Aa[\"{CBNdAAOl}\",[]]A`[\"{CGAjBJl}\",[]]A`[\"{CJB`HNf}\",[]]A`[\"{CGBbHNf}\",[]]A`[\"{CCMjHNf}\",[]]A`[\"{AJ@nHNf}\",[]]Aa[\"{AJE`ABBn}\",[]]Aa[\"{CG@jABBn}\",[]]A`[\"{CKAhHNf}\",[]]A`[\"{CCOlFDf}\",[]]Bl[\"{{{BAIf{B@Jf}}}{{ABBn{{BAIf{GIh}}}}}}\",[]]Aa[\"{B@J`ABFn}\",[]]A`[\"{CKBfGJb}\",[]]Aa[\"{BOGjAANb}\",[]]Dc[\"{{{BAIf{{BJl{CCNj}}}}FDfFDdHNf{BAIf{GIh}}}{{ABEd{MHlABFn}}}}\",[]]Cf[\"{{{BAIf{CCMf}}{BAIf{GIh}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Cf[\"{{{BAIf{CI@j}}{BAIf{GIh}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Cf[\"{{{BAIf{CGAj}}{BAIf{GIh}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Cf[\"{{{BAIf{CG@h}}{BAIf{GIh}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Cc[\"{{{BAIf{CGBf}}{BAIf{GIh}}}{{ABEd{MHlABFn}}}}\",[]]Aa[\"{BOCdABFn}\",[]]Aa[\"{B@LdABBn}\",[]]Bd[\"{{{BAIf{CGCb}}}{{ABBn{GJb}}}}\",[]]A`[\"{CBMdGJ`}\",[]]A`[\"{BDBlGJ`}\",[]]A`[\"{BDF`GJ`}\",[]]Aj[\"{{{BAIf{GIh}}}BILl}\",[]]Ca[\"{{{BAIf{BOCj}}{BAIf{BOCj}}}{{ABBn{AJBb}}}}\",[]]Aa[\"{CFOnABFn}\",[]]Aa[\"{ABD`ABFn}\",[]]Di[\"{{{ONh{CJCl}}{ONh{COJd}}{ONh{COJf}}{ONh{CCLf}}}{{ABEd{BODlABFn}}}}\",[]]Cc[\"{{{ONh{CJCl}}{ONh{COJf}}}{{ABEd{BODlABFn}}}}\",[]]Cf[\"{{{ONh{CJCl}}{ONh{COJf}}DLf}{{ABEd{BODlABFn}}}}\",[]]Cn[\"{{{ONh{CJCl}}{ONh{COJd}}{ONh{COJf}}}{{ABEd{BODlABFn}}}}\",[]]Aa[\"{AJBhABFn}\",[]]Aa[\"{AJBfABFn}\",[]]Aa[\"{B@JfABBn}\",[]]Aa[\"{AJBjABFn}\",[]]Aa[\"{BODnABFn}\",[]]A`[\"{CGBbFDf}\",[]]A`[\"{CJBjFDf}\",[]]o[\"{OInHNf}\",[]]Aa[\"{B@KlABBn}\",[]]Ak[\"{{{BAIf{CGBj}}}BDDh}\",[]]Ak[\"{{{BAIf{BOEl}}}BDDh}\",[]]Aj[\"{{{BAIf{CGAb}}}MHl}\",[]]A`[\"{CAM`FDd}\",[]]A`[\"{B@L`FDd}\",[]]A`[\"{BOB`FDd}\",[]]Aa[\"{CALfABBn}\",[]]Aa[\"{CLKbABBn}\",[]]Aa[\"{BD@jABBn}\",[]]Aa[\"{BOB`ABBn}\",[]]Aa[\"{BD@lABBn}\",[]]Aa[\"{B@JhABBn}\",[]]Aa[\"{BOBjABBn}\",[]]Ai[\"{{{ABBn{FDd}}}FDf}\",[]]A`[\"{BOEnDLf}\",[]]765432Aa[\"{AFGjABFn}\",[]]Aa[\"{CGBlABFn}\",[]]Aa[\"{CAEnABFn}\",[]]Ba[\"{{{BAIf{FEbBOEl}}BOEh}MHl}\",[]]Bf[\"{{{BAIf{BIJn}}{ABBn{AFGj}}}MHl}\",[]]A`[\"{BOCdBLl}\",[]]A`[\"{CGBjBJl}\",[]]Aa[\"{CGB`AANl}\",[]]Aa[\"{CIA`AANl}\",[]]Aa[\"{CCLnABBn}\",[]]Ag[\"{{BIObAJ@`}BIOb}\",[]]Aa[\"{BINbABFn}\",[]]Aa[\"{CKA`ABFn}\",[]]Aa[\"{CGAfABFn}\",[]]Aj[\"{{{BAIf{BJAb}}}DLd}\",[]]Aa[\"{BOBbAFFd}\",[]]o[\"{FDfFDf}\",[]]An[\"{{{BAIf{FEbCC@f}}}CMFb}\",[]]Bh[\"{{{BAIf{FEbCC@f}}}{{ABBn{AIOd}}}}\",[]]0110010101100101011001100101101010010110011010101010100110101001011010011010011001100101101001010110011010100101100101011001100110101010011010101010011010101010011010011001010110011010010101100101100110101010101001010110010101101001100110100101A`[\"{BOGhFDf}\",[]]Aa[\"{AJCdABBn}\",[]]1A`[\"{CFNnFDf}\",[]]Fi[\"{{{BAIf{CCLd}}e}{{BLd{{BJn{ABAb}}}}}GMl{{AB@n{{BAIf{FEbBJ@n}}}{{ABC`{{ABEd{cABFn}}}}}}GMl}}\",[\"T\",\"F\"]]Fi[\"{{{BAIf{CCNj}}e}{{BLd{{BJn{ABAb}}}}}GMl{{AB@n{{BAIf{FEbBJ@n}}}{{ABC`{{ABEd{cABFn}}}}}}GMl}}\",[\"T\",\"F\"]]Aa[\"{CCMlABFn}\",[]]Aa[\"{B@JfABBn}\",[]]Aa[\"{CHOjABBn}\",[]]Aa[\"{CJDbABBn}\",[]]Aa[\"{B@LdABBn}\",[]]3Bi[\"{{c{BLl{BDDd}}}OMl{{GLd{ABFn}}}}\",[\"\"]]o[\"{{}ABEh}\",[]]o[\"{{}AJAh}\",[]]o[\"{{}BAIf}\",[]]Aa[\"{BD@hABFn}\",[]]Aa[\"{AFGjABFn}\",[]]Aa[\"{COJ`ABFn}\",[]]Aa[\"{CAEnABFn}\",[]]Aa[\"{CBNfABFn}\",[]]Aa[\"{CGAjABFn}\",[]]Aa[\"{CAGfAANl}\",[]]Aa[\"{CLK`AANl}\",[]]Ak[\"{{{BAIf{CCLj}}}AANl}\",[]]Aa[\"{BD@jABBn}\",[]]Aa[\"{CBNnABBn}\",[]]Aa[\"{BD@lABBn}\",[]]Aa[\"{B@JhABBn}\",[]]Aa[\"{BOBjABBn}\",[]]Aa[\"{B@JfABBn}\",[]]21A`[\"{CCObBJl}\",[]]A`[\"{CLLhHNf}\",[]]A`[\"{CLLjHNf}\",[]]A`[\"{CK@dDLf}\",[]]l[\"{hFj}\",[]]Aa[\"{BIKdABFn}\",[]]Aa[\"{BOGjAGIj}\",[]]Ak[\"{{{BAIf{CGBj}}}BOAj}\",[]]Ak[\"{{{BAIf{BIMf}}}BOAj}\",[]]Bl[\"{{{BAIf{{BOCb{c}}}}}BOAj{CAKlGMl}}\",[\"B\"]]Aa[\"{BOCbBOAj}\",[]]Ak[\"{{{BAIf{B@Kb}}}BOAj}\",[]]Aa[\"{BOB`ABBn}\",[]]Aa[\"{CGAjABBn}\",[]]Cf[\"{{{BAIf{GMf}}{BAIf{{AAJl{BIn}}}}}{{BAIf{GIh}}}}\",[]]Aa[\"{CCN`ABFn}\",[]]A`[\"{CLLdBJl}\",[]]Bm[\"{{{BAIf{CGA`}}}{{ABBn{{BAIf{B@Jf}}}}}}\",[]]A`[\"{CKAnBJl}\",[]]Aj[\"{{{BAIf{CGBj}}}MHl}\",[]]Am[\"{{{BAIf{FEbBOEl}}}MHl}\",[]]n[\"{{}HNf}\",[]]Cc[\"{{{BAIf{CCL`}}{BAIf{GIh}}}{{ABEd{MHlCCKn}}}}\",[]]Cf[\"{{{BAIf{CC@d}}{BJl{CCNj}}GJb}{{ABEd{MHlABFn}}}}\",[]]Aa[\"{CAGfAANl}\",[]]Bn[\"{{{BIKh{c}}}{{ABEd{eBILd}}}{}{}}\",[\"R\",\"D\"]]00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000l[\"{hFj}\",[]]Bk[\"{{{BAIf{CCMf}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Bk[\"{{{BAIf{CI@j}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Bk[\"{{{BAIf{CGAj}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Bk[\"{{{BAIf{CG@h}}}{{BLd{{BJn{ABAb}}}}}}\",[]]C`[\"{{{BAIf{CBNd}}}{{ABEd{{BLl{BJ@j}}ABFn}}}}\",[]]C`[\"{{{BAIf{BOEl}}AANl}{{BLl{{BAIf{B@Jf}}}}}}\",[]]A`[\"{BJ@dHNf}\",[]]A`[\"{BOE`HNf}\",[]]Aj[\"{{{BAIf{BOEl}}}HNf}\",[]]C`[\"{{{ABBn{{BAIf{GIh}}}}{AIOl{BLj}}AJ@`}HNf}\",[]]Aa[\"{BD@bABBn}\",[]]Aa[\"{BD@jABBn}\",[]]Aa[\"{BD@lABBn}\",[]]Aa[\"{B@JhABBn}\",[]]Aa[\"{BOBjABBn}\",[]]A`[\"{B@JlHNf}\",[]]Aj[\"{{{BAIf{CLJl}}}HNf}\",[]]A`[\"{CNFfHNf}\",[]]Aj[\"{{{BAIf{CAG`}}}HNf}\",[]]Aj[\"{{{BAIf{CN@f}}}HNf}\",[]]Aa[\"{CHObAANl}\",[]]Aa[\"{CHOdAANl}\",[]]10Aa[\"{CJB`ABBn}\",[]]Aa[\"{CJBdABFn}\",[]]Ai[\"{{}{{ABBn{CCLn}}}}\",[]]A`[\"{BOGjHNf}\",[]]A`[\"{CFOhGJ`}\",[]]Aa[\"{BODlABBn}\",[]]A`[\"{AFGjHNf}\",[]]Aa[\"{CMOnBINh}\",[]]Aa[\"{BOD`BINh}\",[]]Aa[\"{CLLhB@Jh}\",[]]Aa[\"{CLLjB@Jh}\",[]]Aj[\"{{{BAIf{GIh}}}ABFn}\",[]]Aj[\"{{{BAIf{GMf}}}AFFd}\",[]]Aa[\"{CAGfAANl}\",[]]A`[\"{BDCnBJl}\",[]]Aa[\"{CLL`ABFn}\",[]]Ak[\"{{{BAIf{B@Jf}}}ABFn}\",[]]Cc[\"{{{ONh{CJCl}}{ONh{COJf}}}{{ABEd{BODlABFn}}}}\",[]]Aa[\"{CHOjABFn}\",[]]Aa[\"{CJDbABFn}\",[]]6Aa[\"{CAL`AANl}\",[]]Db[\"{{{BAIf{{BJl{CCNj}}}}{BAIf{GIh}}}{{ABEd{{BLl{CCMj}}ABFn}}}}\",[]]A`[\"{CIB`BJl}\",[]]Be[\"{{{BAIf{GIh}}{BAIf{GMf}}}AJDb}\",[]]Cn[\"{{{BAIf{CCL`}}{BAIf{GIh}}{BAIf{GIh}}}{{ABEd{MHlCCKn}}}}\",[]]Aa[\"{ABElABFn}\",[]]Bo[\"{{{BAIf{CBNd}}ABFnFDf}{{ABEd{MHlABFn}}}}\",[]]Ai[\"{{{BAIf{OJ`}}}HNf}\",[]]Aa[\"{B@KlABBn}\",[]]Aj[\"{{{BAIf{B@Kb}}}MHl}\",[]]Bh[\"{{{ONh{CCLf}}}{{ABEd{ABFnABFn}}}}\",[]]Aa[\"{CBOfABFn}\",[]]Bd[\"{{{BAIf{GMf}}}{{ABBn{BILl}}}}\",[]]l[\"{hFj}\",[]]Aa[\"{CK@`AANl}\",[]]Fh[\"{{{BAIf{CBMd}}{BAIf{BOBb}}{BAIf{BD@f}}{BAIf{c}}}{{ABEd{CBM`CAG`}}}{{f{GJb{ABBn{GJb}}}}GMlGNb}}\",[\"F\"]]Aa[\"{B@JfABBn}\",[]]8Aa[\"{CAFbBJAb}\",[]]n[\"{{}GJ`}\",[]]o[\"{{}BAIf}\",[]]0000000000000000000000Bj[\"{{{BAIf{FEbCALd}}DLf}{{ABBn{GJ`}}}}\",[]]Aj[\"{{{BAIf{CGCb}}}GJb}\",[]]Ad[\"{{DLfDLf}DLf}\",[]]Aa[\"{BJ@dABBn}\",[]]Aa[\"{BOE`ABBn}\",[]]Be[\"{{{BAIf{BOEl}}}{{ABBn{AANl}}}}\",[]]Aa[\"{BOElABBn}\",[]]Aa[\"{CAGnABBn}\",[]]Aa[\"{ABFlABBn}\",[]]Bh[\"{{{ONh{CCLf}}}{{ABEd{ABFnABFn}}}}\",[]]Bk[\"{{ABFn{ONh{CCLf}}}{{ABEd{MHlABFn}}}}\",[]]Aa[\"{BINbABBn}\",[]]Aa[\"{CKA`ABBn}\",[]]Cf[\"{{{ONh{CCLf}}{ONh{CICb}}CK@l}{{ABEd{FDfABFn}}}}\",[]]Bi[\"{{{BAIf{CCLf}}}{{ABEd{AFFdABFn}}}}\",[]]Bm[\"{{{BAIf{FEbCGAb}}FDdFDd}{{ABBn{MHl}}}}\",[]]Aa[\"{CAM`ABBn}\",[]]Aa[\"{BOB`ABBn}\",[]]A`[\"{CKAnBJl}\",[]]Bl[\"{{{BAIf{CGBj}}}{{ABBn{{BJl{CJCj}}}}}}\",[]]A`[\"{CGBjBJl}\",[]]Aa[\"{BDCnABBn}\",[]]A`[\"{CLLdBJl}\",[]]Cf[\"{{{BAIf{CCLd}}{BAIf{GIh}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Cf[\"{{{BAIf{CCNj}}{BAIf{GIh}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Bl[\"{{{BAIf{BDAd}}OMl}{{ABEd{AANlABFn}}}}\",[]]Aa[\"{CK@lABBn}\",[]]Co[\"{{{ONh{CCLf}}ABFn{ABBn{{BLl{ABFn}}}}}{{ABEd{CHOnABFn}}}}\",[]]Ck[\"{{{BAIf{CCMf}}BJ@h{ABBn{CCLn}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Ck[\"{{{BAIf{CI@j}}BJ@h{ABBn{CCLn}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Ck[\"{{{BAIf{CGAj}}BJ@h{ABBn{CCLn}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Ck[\"{{{BAIf{CG@h}}BJ@h{ABBn{CCLn}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Cd[\"{{{BAIf{CCL`}}{BAIf{GIh}}}{{ABEd{ABFnCCKn}}}}\",[]]Cf[\"{{{BAIf{CCMf}}{BAIf{GIh}}B@Il{ABBn{BOBh}}}ABFn}\",[]]Cf[\"{{{BAIf{CI@j}}{BAIf{GIh}}B@Il{ABBn{BOBh}}}ABFn}\",[]]Cf[\"{{{BAIf{CGAj}}{BAIf{GIh}}B@Il{ABBn{BOBh}}}ABFn}\",[]]Cf[\"{{{BAIf{CG@h}}{BAIf{GIh}}B@Il{ABBn{BOBh}}}ABFn}\",[]]Ch[\"{{{BAIf{BDBh}}{BAIf{GIh}}}{{ABEd{cABFn}}}CG@b}\",[\"T\"]]Bk[\"{{{BAIf{CCMf}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Bk[\"{{{BAIf{CI@j}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Bk[\"{{{BAIf{CGAj}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Bk[\"{{{BAIf{CG@h}}}{{BLd{{BJn{ABAb}}}}}}\",[]]C`[\"{{{BAIf{CI@j}}{BAIf{GIh}}}{{ABBn{AJE`}}}}\",[]]A`[\"{CGBjBJl}\",[]]Df[\"{{{ONh{CNMh}}{ONh{CMGn}}{ONh{CCLf}}ABFnCCLl}{{ABEd{ABFnABFn}}}}\",[]]A`[\"{CK@hBJl}\",[]]Bd[\"{{{BAIf{BODb}}}{{ABBn{DLf}}}}\",[]]Aj[\"{{{BAIf{BODb}}}DLf}\",[]]Aa[\"{CALdABBn}\",[]]Cd[\"{{{BAIf{CGBj}}{BAIf{B@Jf}}}{{ABEd{MHlBIOf}}}}\",[]]Cm[\"{{{BAIf{{BJl{CCNj}}}}FDf{BAIf{GIh}}}{{ABEd{MHlABFn}}}}\",[]]Cf[\"{{{BAIf{CCMf}}{BAIf{GIh}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Cf[\"{{{BAIf{CI@j}}{BAIf{GIh}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Cf[\"{{{BAIf{CGAj}}{BAIf{GIh}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Cf[\"{{{BAIf{CG@h}}{BAIf{GIh}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Be[\"{{{BAIf{CBMh}}{BAIf{GIh}}}MHl}\",[]]Be[\"{{{BAIf{GIh}}{BAIf{GIh}}}BOCj}\",[]]Aa[\"{B@JhABBn}\",[]]Aa[\"{BOBjABBn}\",[]]A`[\"{CLKnBLl}\",[]]21A`[\"{CGAdBLl}\",[]]A`[\"{BODlDLd}\",[]]Bk[\"{{{ABBn{{BAIf{GIh}}}}}{{BAIf{GIh}}}}\",[]]Bh[\"{{{BAIf{GIh}}}{{ABEd{ABFnABFn}}}}\",[]]Am[\"{{{AGIj{FEbADGj}}}MHl}\",[]]Hm[\"{{{BAIf{CG@h}}eg}{{ABEd{cB@Lh}}}{CI@fCAGjOIdGMl}{{ABAb{}{{ABC`{{ABEd{cB@Lh}}}}}}GMl}{{ABAb{}{{ABC`{{ABEd{cB@Lh}}}}}}GMl}}\",[\"T\",\"F1\",\"F2\"]]Aa[\"{CD@`BODf}\",[]]Cc[\"{{{ONh{CJCl}}{ONh{COJf}}}{{ABEd{BODlABFn}}}}\",[]]Aa[\"{AJBhABFn}\",[]]Aa[\"{AJBfABFn}\",[]]Aa[\"{AJBjABFn}\",[]]Aa[\"{BOAhABBn}\",[]]Aa[\"{BD@lABBn}\",[]]?>Bh[\"{{{BAIf{CALn}}}{{ABEd{MHlABFn}}}}\",[]]Ci[\"{{{BAIf{BOBf}}{BAIf{GIh}}HNf}{{BLd{{BJn{ABAb}}}}}}\",[]]A`[\"{CKAnBJl}\",[]]A`[\"{CGBjBJl}\",[]]Am[\"{{{BAIf{FEbCALf}}}MHl}\",[]]Ad[\"{{GJbGIn}GJb}\",[]]Aa[\"{BD@jABBn}\",[]]Aa[\"{BINnABBn}\",[]]8Aa[\"{B@JhABBn}\",[]]Dh[\"{{{BAIf{CI@j}}{BAIf{GIh}}{BAIf{{AAJl{B@Jh}}}}}{{ABEd{AANlB@Lh}}}}\",[]]Co[\"{{{BAIf{CI@j}}{BAIf{GIh}}AANl}{{ABEd{{BLl{B@Jh}}B@Lh}}}}\",[]]Aa[\"{CLKbABBn}\",[]]Aa[\"{BOB`ABBn}\",[]]Bk[\"{{{BAIf{CGBj}}DLf}{{ABEd{MHlABFn}}}}\",[]]Bi[\"{{{BAIf{CLJl}}HNf{ABBn{ABFn}}}MHl}\",[]]Be[\"{{{BAIf{CBMh}}{BAIf{GIh}}}HNf}\",[]]A`[\"{BOElBLl}\",[]]A`[\"{CCOlGJb}\",[]]A`[\"{BIKbGJb}\",[]]?A`[\"{CLLdBJl}\",[]]Aa[\"{CK@`AANl}\",[]]Ao[\"{BAIf{{ABEd{ABFnCD@h}}}}\",[]]0000An[\"{{{BAIf{BDEf}}BIKb}MHl}\",[]]A`[\"{CBOfHNf}\",[]]A`[\"{CCL`HNf}\",[]]Bd[\"{{{BAIf{CGCb}}}{{ABBn{GJb}}}}\",[]]Aa[\"{AJ@`CBMh}\",[]]n[\"{{}GJ`}\",[]]o[\"{{}BAIf}\",[]]Ak[\"{{{BAIf{CGBj}}}CAFb}\",[]]Ak[\"{{{BAIf{CAKl}}}CAFb}\",[]]Ak[\"{{{BAIf{BINl}}}CAFb}\",[]]Aa[\"{BINlCAFb}\",[]]Ak[\"{{{BAIf{BIMf}}}CAFb}\",[]]A`[\"{BOAjHNf}\",[]]Ak[\"{{{BAIf{BDCn}}}CAFb}\",[]]Aa[\"{BDCnCAFb}\",[]]Bl[\"{{{BAIf{{BOCb{c}}}}}CAFb{CAKlGMl}}\",[\"B\"]]Aj[\"{{{BAIf{OJ`}}}B@I`}\",[]]Bk[\"{{{BAIf{{AAJl{BDDd}}}}}{{BLl{ONn}}}}\",[]]Aa[\"{CKAhABBn}\",[]]Eh[\"{{{ONh{CCLf}}{ONh{CNMh}}{ONh{CMOj}}B@FnABFnABFnABFnABFn}{{ABEd{{BLl{FDf}}ABFn}}}}\",[]]Cj[\"{{{BAIf{CGAj}}{BAIf{GIh}}}{{ABEd{{BLl{BIn}}ABFn}}}}\",[]]Cj[\"{{{BAIf{CG@h}}{BAIf{GIh}}}{{ABEd{{BLl{BIn}}ABFn}}}}\",[]]Bk[\"{{{ONh{CCLf}}CLKb}{{ABEd{FDfABFn}}}}\",[]]Aa[\"{CCL`A@@d}\",[]]Aa[\"{CLKbABBn}\",[]]Aa[\"{BOB`ABBn}\",[]]A`[\"{CALbHNf}\",[]]Aa[\"{CCLnABBn}\",[]]Be[\"{{{BAIf{CC@d}}{BJl{CCNj}}}GJb}\",[]]Be[\"{{{BAIf{CC@d}}{BJl{CCNj}}}FDf}\",[]]Ch[\"{{{BAIf{CGAj}}{BAIf{GIh}}}{{ABEd{cB@Lh}}}BIKj}\",[\"T\"]]Ak[\"{{{BAIf{B@Jf}}}ABFn}\",[]]Bn[\"{{{BAIf{{AFDh{ABFn}}}}{BAIf{B@Jh}}}HNf}\",[]]Aj[\"{{{BAIf{B@Jh}}}HNf}\",[]]Bk[\"{{{ONh{CCLf}}ABFn}{{ABEd{HNfABFn}}}}\",[]]A`[\"{B@K`HNf}\",[]]A`[\"{BDCnBJl}\",[]]Aa[\"{CHOfABBn}\",[]]Aa[\"{CBNhABBn}\",[]]A`[\"{BOGjHNf}\",[]]Cb[\"{{{BAIf{{BJl{CCNj}}}}FDf}{{ABEd{MHlABFn}}}}\",[]]Aj[\"{{{BAIf{CK@h}}}MHl}\",[]]Aa[\"{CLK`AANl}\",[]]Ak[\"{{{BAIf{CCLj}}}AANl}\",[]]Aa[\"{CCLjAANl}\",[]]A`[\"{BIKdGJb}\",[]]Aa[\"{BDE`ABFn}\",[]]Cg[\"{{{ONh{CCLf}}ABFnABFn}{{ABEd{{BLl{BINn}}ABFn}}}}\",[]]Bj[\"{{{ONh{CCLf}}FDf}{{ABEd{MHlABFn}}}}\",[]]Cg[\"{{{ONh{CJCl}}{ONh{CCLf}}AANl}{{ABEd{BODlABFn}}}}\",[]]A`[\"{CAKfFDf}\",[]]A`[\"{CCNbFDf}\",[]]Aa[\"{B@KlABBn}\",[]]A`[\"{CLL`FDf}\",[]]A`[\"{CN@bFDf}\",[]]A`[\"{CNFnFDf}\",[]]o[\"{ONdFDf}\",[]]A`[\"{AJCfFDf}\",[]]A`[\"{AFH`FDf}\",[]]Aa[\"{CLKbABBn}\",[]]Aa[\"{BOB`ABBn}\",[]]F`[\"{{{BAIf{BDAd}}OMle}{{ABEd{{ABBn{c}}ABFn}}}{}{{f{{BAIf{BLh}}}{{ABC`{{ABEf{c}}}}}}}}\",[\"T\",\"F\"]]Fa[\"{{{BAIf{CCLd}}OMle}{{BLd{{BJn{ABAb}}}}}GMl{{f{{BAIf{BLh}}}{{ABC`{{ABEf{c}}}}}}GMl}}\",[\"T\",\"F\"]]Fa[\"{{{BAIf{CCNj}}OMle}{{BLd{{BJn{ABAb}}}}}GMl{{f{{BAIf{BLh}}}{{ABC`{{ABEf{c}}}}}}GMl}}\",[\"T\",\"F\"]]Cd[\"{{{BAIf{CGBf}}{BAIf{CIAf}}}{{ABEd{MHlABFn}}}}\",[]]Bl[\"{{{BAIf{{BJl{CCNj}}}}}{{ABBn{ABFn}}}}\",[]]Aj[\"{{{BAIf{GIh}}}ABFn}\",[]]Cd[\"{{{BAIf{CGAj}}{BAIf{{ABEd{cB@Lh}}}}}MHl{}}\",[\"T\"]]Aj[\"{{{BAIf{CLJl}}}MHl}\",[]]Cc[\"{{{BJl{CG@h}}{BJl{CCNj}}}{{ABEd{CHOdABFn}}}}\",[]]Aa[\"{CBNnABBn}\",[]]Aa[\"{BOBjABBn}\",[]]5Gn[\"{{{BAIf{CC@d}}{BJl{CCNj}}{BAIf{GIh}}{BAIf{GIh}}{BAIf{GIh}}{BAIf{{AAJl{BIn}}}}{ABBn{FDd}}{ABBn{FDd}}}{{ABEd{AFFdABFn}}}}\",[]]Ba[\"{{{BAIf{FEbCCOb}}B@Fn}MHl}\",[]]Ba[\"{{{BAIf{FEbCK@h}}B@Fn}MHl}\",[]]An[\"{{{BAIf{CGBj}}B@Hl}MHl}\",[]]Be[\"{{{BAIf{CGBj}}{BJl{CCMf}}}MHl}\",[]]An[\"{{{BAIf{CK@h}}ABFn}MHl}\",[]]Aa[\"{CALbABBn}\",[]]Ah[\"{{}{{AFCh{BLn}}}}\",[]]Da[\"{{{ONh{CCLf}}{ONh{CMOj}}B@FnFDfABFnABFn}{{ABEd{MHlABFn}}}}\",[]]Aa[\"{CIA`ABBn}\",[]]Be[\"{{{BAIf{CGAj}}{BAIf{GIh}}}MHl}\",[]]Aa[\"{CGBhABBn}\",[]]Aa[\"{BIObABBn}\",[]]Aa[\"{B@KbABBn}\",[]]Cd[\"{{{BAIf{CGBf}}{BAIf{CIAf}}}{{ABEd{MHlABFn}}}}\",[]]l[\"{hFj}\",[]]Dc[\"{{{ONh{CCLf}}{ONh{CMGn}}ABFnABFnABFnABFn}{{ABEd{ABFnABFn}}}}\",[]]Aj[\"{{{BAIf{CGBj}}}MHl}\",[]]Am[\"{{{BAIf{FEbBOEl}}}MHl}\",[]]Ed[\"{{{BAIf{BIJn}}{BAIf{GIh}}{BAIf{GIh}}{BAIf{GIh}}{BAIf{GIh}}}{{ABEd{GNjABFn}}}}\",[]]o[\"{{}A@@d}\",[]]n[\"{{}FDd}\",[]]Dj[\"{{{BAIf{CCMf}}{BAIf{GIh}}{BAIf{{AAJl{ABFn}}}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Dj[\"{{{BAIf{CI@j}}{BAIf{GIh}}{BAIf{{AAJl{ABFn}}}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Dj[\"{{{BAIf{CGAj}}{BAIf{GIh}}{BAIf{{AAJl{ABFn}}}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Dj[\"{{{BAIf{CG@h}}{BAIf{GIh}}{BAIf{{AAJl{ABFn}}}}}{{BLd{{BJn{ABAb}}}}}}\",[]]Am[\"{{{BAIf{BOD`}}HNf}HNf}\",[]]Am[\"{{{BAIf{CJCd}}HNf}HNf}\",[]]Df[\"{{{ONh{CJAj}}{ONh{CCLf}}{ONh{CMOh}}}{{ABEd{{ABBn{AFGj}}ABFn}}}}\",[]]Aa[\"{CLK`AANl}\",[]]A`[\"{CCOlGJb}\",[]]Ak[\"{{{BAIf{CAFb}}}ABFn}\",[]]Ce[\"{{{ONh{CCLf}}{ONh{CMOj}}FDf}{{ABEd{MHlABFn}}}}\",[]]B`[\"{{BAIf{AGIj{FEbBIn}}}MHl}\",[]]00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Aa[\"{CAFdABBn}\",[]]Aa[\"{AFE`ABFn}\",[]]Aa[\"{CAGfAANl}\",[]]Cc[\"{{{BAIf{GMf}}{BAIf{GMf}}}{{ABEd{AFFdABFn}}}}\",[]]?>=Ch[\"{{{ONh{CCLf}}ABFnABFn}{{ABEd{{ABBn{CMGf}}ABFn}}}}\",[]]Ba[\"{{{ABBn{{BAIf{GIh}}}}}HNf}\",[]]Aj[\"{{{BAIf{B@Fn}}}HNf}\",[]]n[\"{{}MHl}\",[]]Co[\"{{{ONh{CNMh}}ABFn{ABBn{AANl}}}{{ABEd{{BLl{B@Jh}}ABFn}}}}\",[]]Ef[\"{{{ONh{CCLf}}ABFnABFnABFn{ABBn{ABFn}}{ABBn{ABFn}}{ABBn{FDd}}}{{ABEd{MHlABFn}}}}\",[]]Em[\"{{{BAIf{CGAj}}{BAIf{GIh}}{ABBn{{BAIf{GIh}}}}{ABBn{DLf}}{ABBn{FDd}}}{{ABEd{ABFnB@Lh}}}}\",[]]Ch[\"{{{ONh{CNMh}}ABFnABFn}{{ABEd{{ABBn{B@Jh}}ABFn}}}}\",[]]Eb[\"{{{ONh{CNMh}}ABFnABFn{ABBn{ABFn}}{ABBn{DLf}}{ABBn{FDd}}}{{ABEd{ABFnABFn}}}}\",[]]") \ No newline at end of file diff --git a/api/search.index/generic_inverted_index/d1580d3667.js b/api/search.index/generic_inverted_index/d1580d3667.js new file mode 100644 index 00000000..ebe29510 --- /dev/null +++ b/api/search.index/generic_inverted_index/d1580d3667.js @@ -0,0 +1 @@ +rb_("Q05tADswAAABAAAPAgMAeAX1AMkG9QB7FCMAOzAAAAEAAHUCBwB+BBcAlwQOAKcEAACpBI8AOgU9ALoNiQA8D/UAOzAAAAEAAOsBAgBFCfUARg71ADswAAABAAD2AGAASgMAAFcLAADME3sAbRQAACQVAAAnFQEAKhUAACwVAAAuFQAAMBUAADIVAAA1FQAANxUAADkVAAA7FQEAPhUAAEAVAABCFQAARBUAAEcVAABJFQEATRUBAFEVAQBUFQAAVhUAAFgVAABbFQAAXRUAAF8VAABhFQEAZBUAAGYVAABpFQAAaxUBAG4VAABxFQAAcxUBAHcVAAB5FQEAfBUAAH8VAQCCFQAAhRUAAIcVAQCLFQAAjRUAAI8VAQCSFQAAlBUAAJYVAACZFQEAnRUBAKEVAACjFQEAphUAAKkVAACrFQEArxUBALIVAAC1FQEAuBUAALsVAQC/FQAAwRUBAMUVAQDJFQAAyxUAAM0VAQDQFQAA0xUBANYVAADZFQAA2xUBAN4VAADhFQEA5BUAAOYVAADoFQAA6hUAAOwVAADvFQAA8RUBAPUVAAD3FQAA+RUAAPsVAQD+FQAAABYAAAIWAAAEFgAABxYAAAkWAAALFgAADRYAAA8WAAARFgEAOzAAAAEAAHICCABAAwAAshAAAMgQAABVEXYARBL1ADwTiQDSFAAA7Rd3AAS1DQAAihcAANkXAACLGQAAOjAAAAEAAAAAAHkAEAAAACUVJhUpFSsVLRUvFTEVMxU0FTYVOBU6FT0VPxVBFUMVRRVGFUgVSxVMFU8VUBVTFVUVVxVZFVoVXBVeFWAVYxVlFWcVaBVqFW0VbxVwFXIVdRV2FXgVexV9FX4VgRWDFYQVhhWJFYoVjBWOFZEVkxWVFZcVmBWbFZwVnxWgFaIVpRWnFagVqhWtFa4VsRWzFbQVtxW5FboVvRW+FcAVwxXEFccVyBXKFcwVzxXRFdIV1RXXFdgV2hXdFd8V4BXjFeUV5xXpFesV7RXuFfAV8xX0FfYV+BX6Ff0V/xUBFgMWBRYGFggWChYMFg4WEBYTFsEZsBoEIAMAAGENAAASGQAAnBkAAAOlBgAAlAgAAMcIAAA7MAAAAQAA9QEMAFgA9QBoAfUAmQYAAKgGAACzBgAArg0AAMMQAADlEQAAuxQAAFYbAAAqHQAAlx8AAAZXHQAAYh0AAGwdAADqHgAAjB8AAIIgAAACrhoAACcdAAACxBAAAN8eAAADpBQAAKYUAAC7GQAABsUQAADGEAAApRQAAKcUAAC8GQAAvRkAAANJDQAAghcAAIMXAAABqhgAAAAAAAAAAjEZAACFIAAAAAHoHAAAQUhvAAAAOzAAAAEAAPUAAQBGDvUAADswAAABAAB3AAEA7Rd3AAA6MAAAAQAAAAAAdwAQAAAAJRUmFSkVKxUtFS8VMRUzFTQVNhU4FToVPRU/FUEVQxVFFUYVSBVLFUwVTxVQFVMVVRVXFVkVWhVcFV4VYBVjFWUVZxVoFWoVbRVvFXAVchV1FXYVeBV7FX0VfhWBFYMVhBWGFYkVihWMFY4VkRWTFZUVlxWYFZsVnBWfFaAVohWlFacVqBWqFa0VrhWxFbMVtBW3FbkVuhW9Fb4VwBXDFcQVxxXIFcoVzBXPFdEV0hXVFdcV2BXaFd0V3xXgFeMV5RXnFekV6xXtFe4V8BXzFfQV9hX4FfoV/RX/FQEWAxYFFgYWCBYKFgwWDhYQFhMWAAA7MAAAAQAA6wECAFgA9QBoAfUAAlcdAACMHwAAAScdAAABxBAAAAOkFAAAphQAALsZAAAGxRAAAMYQAAClFAAApxQAALwZAAC9GQAAAoIXAACDFwAAAAAAAAAAAjEZAACFIAAAAAHoHAAAQ2QAAAAAAAAAAAAAOzAAAAEAAOsBAgBYAPUAaAH1AAAAAAAAAAAAAAAAAAExGQAAAAHoHAAAQ2AAAAAAAAAAAAAAOzAAAAEAAOsBAgBYAPUAaAH1AAAAAAAAAAAAAAAAAAAAAegcAAA=") \ No newline at end of file diff --git a/api/search.index/name/67aaea6da5.js b/api/search.index/name/67aaea6da5.js new file mode 100644 index 00000000..39bec004 --- /dev/null +++ b/api/search.index/name/67aaea6da5.js @@ -0,0 +1 @@ +rd_("b()b->bFnbGlbIobOkbOrbRcbTvb[]beq00000000000000000000000000000000000000000bfnbgl0bid00000000000000000000000000000000bor00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bu8cALL0cAll0cAndcAnycArccBox0cEndcErrcHlscIntcMapcOff0cOnecOrdcPincPopcRowcUtccVeccWrycaddcand00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ccmpcctxcendcf32cf64cfbocfmt00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cget00ci32ci64clencmpv000cmutcnew00000000000000000000000000000000000cruncsetcsid00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000csqlcstrctag0cu16cu32cu64curl00000dBooldDropdFlatdFromdHttp0dIdle000dIntodJazzdLogodLouddNextdNone0dNull0dPathdRealdRockdSenddSpandStopdSyncdTime0dTypedUserdVZipdWifidauth000dbody0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dbooldconn00ddrawddrop0000demit0dfrom000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dinto00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ditem000dkind0dload0000dlock0dmaindmode00dname0000000000000000000000dnext0dopen000dpath00dpingdplay000000dportdpostdpushdrate0drole0dseek0000000dsend0dsize0dstop00000000dsync0dunitduser00dvzip00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000eAlbum0000eAlloweAudio000eCloneeDebugeEmptyeEndedeError00000000eFloateGenreeHtml5eInt64eLocal00eMbps1eMbps2eMbps4eMbps8eMovie00eMusiceMutex0eOther00ePauseePhase0eQueryeQuieteReadyeSizedeStart0eStateeThumbeTrackeValue00eVideo0eVocalealbum0000earrayec_intecache00eclear00eclone00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000eclose00ecodec00eemptyeerror00egainseindex000einnereitems00000000elabel000elevelelimit0eloginemedia0000000emuted00000epause000000ephase0equeue0000ereset0erouteescopeesizesesliceestart00000estate00000000000estatsetitle000etokenetotaletouchetupleetypes000eusizeeutils0evideoewidthfArtistfBannerfBorrowfClient0fCustom000fFailed00fFnOncefFolderfFuturefHeaderfMbps10fMbps20fMoviesfNative000000fNormal0fOptionfOutput0fPaused0000fPerson0fPolicyfPrefixfQueued0fRemote000fResult00fRwLock0fSearch0fSeasonfSeekTofSeriesfServer00fSharedfString0fTargetfTvShow0fTypeIdfactionfactorsfalbumsfartist000fas_strfattachfborrow00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fc_charfc_voidfcancelfclient0000fconfig0000fcreatefdeletefdetachfdetail0fdevice0fdomain0fenable0fevents00ffailedffieldsfformatfgenres0000fhandlefheaderfheightfhybridfinline00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000flogoutfmethodfmodels0fonline0fparamsfpeople000fplayer000fpushedfreason0frecordfredactfremote0fremovefrender0frepeat000freport0fresultfschema0fsearch000fsharedfsignalfsourcefstatus00ftarget0ftimers0fvolume000000000fwidgetfworker0gAdvancegBuildergChannelgContextgDefaultgDisplaygEpisodegGL_RGBAgHashMapgHashSetgKbps720gKeyring0gLibrarygLoading0gNetwork0000gOfflinegOpeninggPathBufgPlaying00gPrimarygRefCellgRequest0gRuntimegSeeking0gSessiongStarted0gStopped00gToOwnedgTryFromgTryIntogUnknowng_configgartists000000gbackend000gbitrate00gcatalog0gcomparegcontext00000gcurrent00gdecryptgdefault00000000000000000000000000000000000gdestroy0gdisable0gdismissgenabled00gencryptgexecute00ghandlesghistorygimdb_idginit_glgitem_id00000000000000000000000000000glibrary0glog_dirgmessage00000000gmigrategnetwork0goffline000gpayload0gpercentgpinning0gpointergprimary0gquality00greportsgresolvegseek_togservicegsession000gshuffle000000gskip_togskippedgsort_bygstorage000gsuccessgtexturegtimeoutgtmdb_idgtype_id00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000gurl_forguser_id0000000000000gversion00gwebviewhAPP_NAME0hBackdrophCellularhDataTypehDatabase00hDateTimehDbResulthDurationhENV_FLAGhEQ_BANDShEpisodes0hEqPresethEthernethFavorite0hFinishedhGL_RGBA8hGenericshLocalKeyhNotFound00hOnceLockhOrderinghOriginalhPlaylist000000hProgress000hQueuedOp0hREDACTEDhResolvedhResponse0hSpectaIDhSubtitlehSyncSinkhToStringhUserDatahUserInfohUserSkiphUserStophVerified0hadded_athalbum_id0000000000000hattemptshautoplay00hbase_urlhcan_seekhcommands0hdeferredhdownload00000hduration000000000000000hend_datehend_timehget_item000hget_jsonhget_modehhandlershhas_itemhhas_next00himage_id00his_mutedhitem_idshjellyfin0hjellytauhlanguage00hmedia_id0hobservedhoverview0000hpin_itemhplatformhplaylist0hpoll_nowhposition00000000000000000000000hprevious0hpriority00hprogress0hprotocolhregisterhregistryhreporter000hresolvedhseekable0hsessions00hset_modehset_rate00hsettings000hsnapshot00hspan_forhteardownhthrottlehto_owned00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000htrack_id0htry_from00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000htry_into00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000husername00hverifiediAppHandleiBassBoostiBorrowMutiBuffering0iCHUNK_LENiCancelled0iClassicaliCompleted0iDEVICE_IDiDirectUrl0iEndReason0iErrorImpliErrorKindiForbiddeniFormatteriGL_LINEARiImageTags0iImageTypeiJRayActor0iLocalFileiMediaItem00iMediaKind0iMediaType0iMpvPlayeriMutexSafeiNamedTypeiNoneValueiPartialEqiPlayStateiPolicyExtiQueuedRowiReferenceiRenditioniRepoError0iSemaphoreiSerialize0iSubstringiTranscodeiTransport0iWordStartiabandonediavailableicache_diricontainer000idevice_id00idownloadsievict_lruiexoplayerifavorites0ifile_path0000ifile_size0iget_items000iget_limitiget_tokenihas_frameihas_mediaiimage_tag0iis_active000000iis_folder00iis_forced0iis_paused0000iis_played000iis_seasoniitem_name0000iitem_type0000000iitems_mutikind_rankilast_itemilock_safeilog_filesimax_width0imime_type000imove_itemioperation0iparent_id000iperson_idiplay_itemipost_jsoniquery_one00iqueued_atiread_safeirecursiveireference000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000iremaining0irenditionirun_drain0isanitisediseason_id000iselection0000iserialize0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000iseries_id0000000iserver_id00000000000000iset_limitiset_muted00iset_queue0isubtitles0ithresholdithumbnail0ito_string0000itrack_ids0itransientitransport000iunmetered0iuser_data0iuser_nameiwifi_onlyjAlbumTrackjArtistItemjAtomicBooljAuthResultjCachedItem0jCastActivejCommandArgjComparablejConnectionjDirectPlayjEncryptionjEndOfTrackjEpisodeRowjEquivalent0jFileSystemjGL_TEXTUREjHttpClient0jHttpConfig0jLEVEL_FILEjMIGRATIONSjMPV_HANDLEjMarkPlayed000jMpvBackend0jMutexGuardjPartialOrdjQueryParamjRepeatMode0jRwLockSafejSerializer0jServerInfo0jSkipActionjSmartCachejStreamKind0jStructured0jSyncConfigj_cancelledjalbum_name0000000000000japp_handle00jauth_loginjbatch_sizejborrow_mut00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000jbytes_usedjclear_itemjclone_into00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000jcontext_idjcreated_atjdb_service00jend_reasonjequivalent000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000jexclusions0jfile_countjget_configjget_genres000jget_person000jget_statusjimage_tags0jimage_typejis_default00jis_enabledjis_episodejis_libraryjis_playing00jis_running0jis_shuffle0jis_stoppedjitem_count0jitem_typesjlevel_namejlibrary_id00jlocal_filejmaster_macjmax_height000jmedia_type00jmpv_handlejmpv_player0jmpv_render0jnull_value00000000000000000000000000000000000jplay_count00jplay_queuejplay_statejquery_many000jread_queuejrepository000jrequest_idjsave_tokenjserver_url0000jsession_id0jset_volume000000jsize_bytesjslave_macsjsort_order00jstarted_atjstream_end0jstream_url0000jsync_drain0jtarget_dirjthumbnails0jtotal_sizejtrack_playjunpin_itemjwrite_safekAPP_VERSION0kAddPositionkAuthManagerkCacheConfig00kChannelItemkCommandItemkDeserializekDrainReportkEQ_GAIN_MAXkEQ_GAIN_MINkFunctionArgkInvokeErrorkIpcResponsekJRayContextkKeepPlayingkLevelFilterkLiveChannelkMediaLoaded0kMediaPlayer0kMediaServerkMediaSource000kMediaStreamkNeedsReauth0kNetworkTypekNullBackend0kOfflineItemkOpenRequest0kPlayerError0kPlayerState00kPollingHintkProgressivekPushFailurekQueueStatuskScopeObjectkSearchScopekSessionInfokTauriPluginkTransactionkTrebleBoostkVolumeLevelk_db_servicek_processingk_repositorykalbum_countkartist_name0000kartwork_urlkaudio_codec00kaudio_track00kauth_headerkauth_logoutkclear_cachekclear_queuekconversions000kcredentials0kdelete_itemkdeserialize00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000kdevice_namekdiagnostics000kdirect_playkdownload_id000000kdrop_targetkduration_ms0kformat_timekframe_readykget_item_idkget_session0khas_content0000khttp_client0000kis_checkingkis_favorite0000kis_playbackkitems_countkjellyfin_id0klimit_bytesklogged_sizekmark_failedkmark_played000kmark_syncedkmaster_namekmax_bitrate0kmax_retries00kmpv_backendkparse_levelkpartial_cmpkperson_type0kplayer_nextkplayer_playkplayer_seekkplayer_stopkplaylist_id0000kposition_ms0krecoverablekrepeat_mode00kreport_swapkretry_count00ksearch_rankksearch_termkseason_name00000kseason_rankkseek_offsetkseries_name00000kserver_name00kset_contextkset_sessionkslave_namesksleep_timer0ksmart_cache0kstart_index00kstarting_atkstream_type00ktarget_lufsktarget_pathkticks_to_ms0kto_datatype00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ktotal_bytes0ktotal_itemsktrack_countktransaction0kvideo_codec0000kvideo_widthkwith_paramslCachedPersonlCapabilitieslDeserializerlDirectStreamlDownloadInfolDownloadTasklEXCLUDED_IDSlFavoriteSinklImageOptionslJellyfinItemlJellyfinUserlLIVE_SURFACElLegacyPlayer0lLmsSyncGrouplMarkUnplayed0lMatchQualitylNetworkError0lNetworkStatelObservedTimelPlaybackInfolPlaybackKindlPlaybackMode0lPlayerStatuslPlaylistItemlQueueChanged0lQueueContext00lQueueManager0lReloadStream00000lReporterSinklResumeStream0lSERVICE_NAMElSearchResultlStateChanged0lStorageStatslSurfaceStatelVideoBackendlVideoSurfacelaccess_token00000lactive_count00lalbum_artist0000lartist_items00lauth_managerlauto_advance0lbase_secondslbind_texturelcache_subdirlcallback_ctxlcapabilities0000lcompleted_atlconnectivity0000lcontent_typelcontext_typelcurrent_hintlcurrent_itemlcurrent_modelcycle_repeat0ldefault_trueldelete_tokenlensure_spacelfailed_countlfrom_command00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000lgen_textureslget_channels000lget_sessionslget_upcominglhas_previous00lindex_is_duelindex_number0000lis_containerlis_reachable0lis_retryable0litems_cached0litems_pruned0ljellytau_libllast_checkedllibrary_namellist_optionsllogged_no_gllmax_episodeslmedia_playerlmedia_server0lmerged_medialnative_video0lneeds_reauthlnetwork_type0lnext_episode0lnormalise_idlpartial_pathlpaused_countlpending_seeklplay_commandlplayable_urllplayer_pauselprofile_type0lqueued_count0lread_pendinglrepositorieslresolve_pathlsave_to_filelsearch_querylseries_prefs0lsession_seeklshould_retrylshuffle_modelspawn_eventslstorage_initlstorage_typelstored_levelltex_image_2dltotal_tracksltry_downloadlvideo_heightlvolume_level0mActiveSessionmAdvanceToNextmAudioSettingsmCachedLibrarymCastDiscoverymCloneToUninitmCountdownTick0mDeviceProfilemDownloadErrormDownloadEvent0mDownloadStatsmEncryptedFilemExcludeHiddenmGL_TEXTURE_2DmInternalStatemItemsResponsemMIGRATION_001mMIGRATION_002mMIGRATION_003mMIGRATION_004mMIGRATION_005mMIGRATION_006mMIGRATION_007mMIGRATION_008mMIGRATION_009mMIGRATION_010mMIGRATION_011mMIGRATION_012mMIGRATION_013mMIGRATION_014mMIGRATION_015mMIGRATION_016mMIGRATION_017mMIGRATION_018mMIGRATION_019mMIGRATION_020mMIGRATION_021mMIGRATION_022mMIGRATION_023mNamedDataTypemPanicHookInfomPlaybackEndedmPlaybackStart0mPlayerBackend0mPlaylistEntrymPreloadResultmQualityOptionmResumeTrackermSearchOptionsmSubtitleTrack0mSyncProcessor0mSyncQueueItemmVideoSettingsmVolumeChanged0mallow_attemptmaudio_bitratemcalc_progressmcurrent_index000mcurrent_mediamdeferred_seekmdevice_get_idmdevice_set_idmdisplay_title0mdownload_itemmdownload_rootmensure_targetmerror_message0mevent_emitter0000mexecute_batch0mexecute_querymexpected_sizemfrom_jellyfin0mget_downloadsmget_favorites000mget_from_filemget_image_url000mget_json_fastmget_libraries000mget_user_datamhtml5_playingmimage_get_urlmis_monitoringmlast_durationmlast_position0mlegacy_playermload_and_playmmark_deferredmmark_favorite000mmark_reportedmmatch_qualitymmedia_sources00mmedia_streams00mmerged_volumemmime_from_extmnormalize_urlmon_mpv_updatemparallel_racemplayback_kindmplayback_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_bitratemvideo_surface0mwith_durationnAuthentication00nControlCommand0nDownloadResultnDownloadWorker0nEventThrottler0nFunctionResultnGL_FRAMEBUFFERnHandOffToAudionIndexPassGuardnJellyfinClient0nJellyfinConfignLiveStreamInfonNewTrackLoadednNowPlayingItem0nPositionUpdate0nSECRET_HEADERSnSecurityStatusnSessionChanged0nSleepTimerMode00nThumbnailCache0nTypeCollectionnaudio_settings00000000nclassify_errornconvert_paramsndevice_profile00ndownload_albumndownload_bytes0ndownload_videonencryption_keynepisode_number0nexceeds_sourcenfavorites_onlynget_cache_sizenget_item_countnget_json_innernget_stream_urlnis_excluded_bynis_in_progressnis_item_pinnednis_video_medianlast_seek_timenlast_synced_atnlive_stream_idnlogged_no_sizenmark_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_urlnsource_bitratenspecta_buildernstart_downloadnstart_positionnstop_transcodensubtitle_track00nsync_processornsync_to_serverntex_parameterinthumbnail_saventoggle_shuffle0nverify_sessionoCredentialErroroCredentialStoreoDatabaseService0oDatabaseWrapperoDiagnosticsInfooDownloadManageroGetImageRequestoGetItemsOptionsoHtml5NativeSeekoLibrarySettingsoLocalNativeSeekoMediaRepositoryoMergedMediaItemoPendingFavoriteoPlayItemRequestoPlaybackContext0oPlaybackStopped0oRETRY_DELAYS_MSoRusqliteService0oRwLockReadGuardoSPECIALS_SEASONoSessionVerifier0oSessionsUpdated0oSleepTimerState0oSmartCacheStatsoStreamSelection0oSubtitleProfileoThumbnailWorker0oTryReserveErroroadd_to_playlist000oallows_download0oauth_initializeoavailable_slotsoavailable_spaceobuild_af_filterocancel_downloadoclone_to_uninit00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ocollection_type0ocompleted_countoconfine_to_rootocreate_playlist000ocurrent_episode0ocurrent_sessionodelete_downloadodelete_playlist000odelete_texturesodownload_seasonodownload_seriesodownload_sourceoequalizer_bandsoevict_lru_asyncofavorites_scopeofor_queued_itemoget_auth_headeroget_cached_pathoget_device_name0oget_jray_actors0oimage_semaphoreointo_media_itemois_episode_itemois_html5_activeois_transferring0ojellyfin_client0000omedia_local_urlomedia_source_id00onamed_data_type00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000onot_implementedoofficial_rating000oopen_generationoparse_queued_opopeek_end_reasonoplay_on_sessionoplay_queue_fromoplay_session_id000000oplayer_previousoplaylist_createoplaylist_deleteoplaylist_renameopost_json_inneroproduction_year000orecord_positionoremaining_countorename_playlist000orenderer_codecsoresume_downloadosave_to_keyringoseries_progress0oset_audio_track0oset_report_sinkoset_sleep_timeroshould_downloadostop_monitoringosync_clear_userotake_end_reasonotoggle_playbackotranscoding_urlounmark_favorite000oupdate_local_dbowith_start_timeA`AlbumStorageInfoA`AutoplayDecision00A`AutoplaySettings0A`BackendInitErrorA`BackgroundActionA`CachedItemPersonA`CredentialResultA`DeserializeOwnedA`GL_UNSIGNED_BYTEA`HybridRepository0A`JellyfinUserDataA`MediaSessionType00A`MpvRenderContextA`NegotiatedSourceA`NegotiatedStreamA`OnlineRepository0A`PlayQueueContext0A`PlayQueueRequestA`PlaybackProgressA`PlaybackReporter0A`PlaybackSnapshotA`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`bind_framebufferA`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_serialize0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000A`first_failure_atA`format_time_longA`gapless_playbackA`gen_framebuffersA`get_album_tracksA`get_from_keyringA`get_latest_items000A`get_proc_addressA`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`logged_no_windowA`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_time_ticksA`start_tv_sessionA`storage_get_itemA`storage_get_pathA`storage_get_sizeA`stream_selectionA`sync_get_pendingA`sync_mark_failedA`ticks_to_secondsA`total_size_bytes0A`tv_session_endedA`without_excluded00AaAddToQueueRequestAaBackendNativeSeekAaCATALOG_INDEX_TTLAaCatalogIndexEventAaCatalogSyncResultAaCatalogSyncStatusAaDiagnosticsBundleAaDirectPlayProfileAaDownloadDiskUsageAaDownloadsResponseAaHtml5ReloadStreamAaINDEX_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_player0Aalogged_first_drawAamerged_is_playingAaneeds_transcoding0000Aaobserved_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_downloadAaregistered_handleAaremaining_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_profiles0Aasync_full_catalogAathreshold_reachedAathrottle_durationAatransfer_to_localAause_html5_elementAavolume_to_percentAawith_connectivityAbAuthManagerWrapperAbCATALOG_INDEX_TICKAbCATALOG_ITEM_TYPESAbConnectivityStatusAbDEFAULT_TIMEOUT_MSAbExclusionCandidateAbInvokeResponseBodyAbLIBRARY_HOLDS_ITEMAbLockscreenMetadataAbMediaServerWrapperAbNORMALIZE_REF_LUFSAbNORMALIZE_REF_PEAKAbNO_SUBTITLE_STREAMAbNetworkStateHandleAbPlaybackReportSinkAbPlayerEventEmitter0AbPlayerStateWrapperAbResumeQueuedResultAbSharedEventEmitterAbTranscodingProfileAbVIDEO_PLAY_SESSIONAb_calculate_backoffAbalbum_play_historyAbalready_downloadedAbapply_probe_resultAbaudio_stream_indexAbcache_with_timeoutAbcalculate_intervalAbcalculate_progressAbcan_download_asyncAbcan_start_downloadAbcancel_sleep_timerAbcheck_reachabilityAbcrossfade_durationAbdevice_total_bytesAbdiagnostics_exportAbduration_from_secsAbemit_html5_controlAbemit_queue_changedAbget_playlist_items000Abinclude_item_types0Abinstall_panic_hookAbkind_from_jellyfin0Ablogged_first_frameAblogged_render_failAbmax_audio_channels00Abmax_static_bitrateAbmove_playlist_item000Abnegotiate_playbackAbnormalize_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_onlyAbselect_audio_track00Abserved_audio_codec0Absession_set_volumeAbset_audio_settings00000Abset_max_concurrentAbset_subtitle_track0Abshould_cache_albumAbstart_timer_threadAbstorage_get_personAbsubtitle_switchingAbsupported_commandsAbtest_keyring_innerAbtotal_record_count00Abtransfer_to_remoteAbupdate_audio_trackAbuses_webview_audioAbvideo_audio_codecsAcAddTrackByIdRequestAcAlbumAffinityStatusAcAlbumRecommendationAcBackendReloadStreamAcBackgroundAudioPlanAcCATALOG_INDEX_EVENTAcConnectivityMonitorAcDEFAULT_MAX_RETRIESAcDownloadItemRequestAcJellyfinMediaSourceAcJellyfinMediaStreamAcMediaSessionManager0AcNetworkStateWrapperAcPlaybackInfoRequestAcPlaybackModeChanged0AcPlaybackModeManagerAcRemoteSessionStatusAcRemoteVolumeControlAcSUBTITLE_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_framebuffersAcdelete_from_keyringAcdetect_audio_systemAcdo_erased_serialize0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Acdownload_with_retryAcengine_capabilitiesAcenvironment_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_clientAcset_update_callbackAcstart_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_CODECAdGL_COLOR_ATTACHMENT0AdJellyfinPlaylistItemAdLIBRARY_SETTINGS_KEYAdPlaybackCapabilitiesAdPlaybackInfoResponseAdPlaybackStartRequestAdSKIP_FORWARD_SECONDSAdSessionPollerManagerAdSessionPollerWrapperAdShowNextEpisodePopup000AdVideoSettingsWrapperAdWEBVIEW_AUDIO_CODECSAdaudio_track_languageAdchanged_favorite_idsAdconnectivity_monitorAddecide_playback_kindAddelete_all_downloadsAddiagnostics_get_infoAddirect_play_profilesAdextract_jellyfin_idsAdget_active_downloadsAdget_audio_stream_url000Adget_credentials_pathAdget_downloaded_items0Adget_items_unfilteredAdget_live_tv_channels000Adget_next_up_episodes000Adget_stream_selection0Adget_video_stream_url0Adis_playing_or_pausedAdlibrary_get_settingsAdlibrary_set_settingsAdmark_download_failedAdmerge_search_resultsAdmpv_render_update_fnAdoffline_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_play00Adsupports_transcoding00Adsync_mark_processingAdsync_process_pendingAdthumbnail_get_cachedAdtranscoding_profilesAdunique_tracks_playedAeAddTracksByIdsRequestAeDEFAULT_REMOTE_VOLUMEAeFavoritesChangedEventAeFunctionResultVariantAeGL_TEXTURE_MAG_FILTERAeGL_TEXTURE_MIN_FILTERAeLAST_CATALOG_SYNC_KEYAeMAX_PROGRESS_FRACTIONAeMIN_PROGRESS_FRACTIONAePlayAlbumTrackRequestAePlaylistCreatedResultAePlaylistItemsResponseAeSTREAMING_QUALITY_KEYAeSeriesAudioPreferenceAeStreamQualityResponse0AeTICKS_PER_MILLISECONDAeThumbnailCacheWrapperAeaudio_track_switchingAeauto_open_live_streamAebackground_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_fileAemax_streaming_bitrate0Aemedia_local_selectionAenote_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_fileAeselect_subtitle_track00Aeset_autoplay_settingsAeset_excluded_item_idsAeset_playback_reporterAeset_streaming_qualityAeshould_precache_queueAespawn_catalog_indexerAespawn_download_workerAespawn_favorites_drainAestorage_delete_serverAestorage_get_librariesAesubtitle_stream_indexAesupports_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_audioAfframebuffer_texture_2dAfgenerate_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_stream00Afsupports_media_controlAfsync_cleanup_completedAfsync_get_pending_countAftest_keyring_availableAfwith_countdown_clampedAfwith_crossfade_clampedAgConnectivityChangeEventAgFALLBACK_AUDIO_CHANNELSAgFAVORITES_CHANGED_EVENTAgGL_FRAMEBUFFER_COMPLETEAgPlaybackProgressRequestAgPlaybackReporterWrapperAgRETRY_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_downloadAhcheck_framebuffer_statusAhclamp_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_FORMATSAiPLAYBACK_QUALITY_OVERRIDEAiRemoteDisconnectRequestedAiaudio_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_quality_overrideAiplayback_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_volumeAirenderer_can_decode_audioAireport_html5_media_loadedAireport_progress_throttledAirepository_jray_actors_atAirestore_streaming_qualityAiseeks_transcoded_in_placeAisessions_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_type00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Ajget_download_manager_statsAjget_download_storage_statsAjis_background_audio_activeAjparent_backdrop_image_tags00Ajplayer_report_media_loadedAjquality_options_for_sourceAjremote_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_urlAkeffective_streaming_qualityAkfetch_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_urlsAmset_playback_quality_overrideAmstorage_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_SECSAoclear_playback_quality_overrideAoplayback_mode_get_remote_statusAoplayback_mode_transfer_to_localAorepository_get_audio_stream_urlAorepository_get_downloaded_itemsAorepository_get_live_tv_channelsAorepository_get_next_up_episodesAorepository_get_stream_selectionAorepository_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/92d78e910f.js b/api/search.index/normalizedName/92d78e910f.js new file mode 100644 index 00000000..ea069c1f --- /dev/null +++ b/api/search.index/normalizedName/92d78e910f.js @@ -0,0 +1 @@ +rd_("b()b->bfnbglbiobokborbrcbtvb[]beq00000000000000000000000000000000000000000877bid00000000000000000000000000000000555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555bu8call000candcanycarccbox0cendcerrchlscintcmapcoff0conecordcpincpopcrowcutccveccwrycaddcand00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ccmpcctxcendcf32cf64cfbocfmt00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cget00ci32ci64clencmpv000cmutcnew00000000000000000000000000000000000cruncsetcsid00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000csqlcstrctag0cu16cu32cu64curl00000dboolddropdflatdfromdhttp0didle000dintodjazzdlogodlouddnextdnone0dnull0dpathdrealdrockdsenddspandstopdsyncdtime0dtypeduserdvzipdwifidauth000dbody0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dbooldconn00ddrawddrop0000demit0dfrom000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dinto00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ditem000dkind0dload0000dlock0dmaindmode00dname0000000000000000000000dnext0dopen000dpath00dpingdplay000000dportdpostdpushdrate0drole0dseek0000000dsend0dsize0dstop00000000dsync0dunitduser00dvzip00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ealbum0000ealloweaudio000ecloneedebugeemptyeendedeerror00000000efloategenreehtml5eint64elocal00embps1embps2embps4embps8emovie00emusicemutex0eother00epauseephase0equeryequietereadyesizedestart0estateethumbetrackevalue00evideo0evocalealbum0000earraydcintecache00eclear00eclone00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000eclose00ecodec00eemptyeerror00egainseindex000einnereitems00000000elabel000elevelelimit0eloginemedia0000000emuted00000epause000000ephase0equeue0000ereset0erouteescopeesizesesliceestart00000estate00000000000estatsetitle000etokenetotaletouchetupleetypes000eusizeeutils0evideoewidthfartistfbannerfborrowfclient0fcustom000ffailed00ffnonceffolderffuturefheaderfmbps10fmbps20fmoviesfnative000000fnormal0foptionfoutput0fpaused0000fperson0fpolicyfprefixfqueued0fremote000fresult00frwlock0fsearch0fseasonfseektofseriesfserver00fsharedfstring0ftargetftvshow0ftypeidfactionfactorsfalbumsfartist000easstrfattachfborrow00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000eccharecvoidfcancelfclient0000fconfig0000fcreatefdeletefdetachfdetail0fdevice0fdomain0fenable0fevents00ffailedffieldsfformatfgenres0000fhandlefheaderfheightfhybridfinline00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000flogoutfmethodfmodels0fonline0fparamsfpeople000fplayer000fpushedfreason0frecordfredactfremote0fremovefrender0frepeat000freport0fresultfschema0fsearch000fsharedfsignalfsourcefstatus00ftarget0ftimers0fvolume000000000fwidgetfworker0gadvancegbuildergchannelgcontextgdefaultgdisplaygepisodefglrgbaghashmapghashsetgkbps720gkeyring0glibrarygloading0gnetwork0000gofflinegopeninggpathbufgplaying00gprimarygrefcellgrequest0gruntimegseeking0gsessiongstarted0gstopped00gtoownedgtryfromgtryintogunknownfconfiggartists000000gbackend000gbitrate00gcatalog0gcomparegcontext00000gcurrent00gdecryptgdefault00000000000000000000000000000000000gdestroy0gdisable0gdismissgenabled00gencryptgexecute00ghandlesghistoryfimdbidfinitglfitemid00000000000000000000000000000glibrary0flogdirgmessage00000000gmigrategnetwork0goffline000gpayload0gpercentgpinning0gpointergprimary0gquality00greportsgresolvefseektogservicegsession000gshuffle000000fskiptogskippedfsortbygstorage000gsuccessgtexturegtimeoutftmdbidftypeid00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000furlforfuserid0000000000000gversion00gwebviewgappname0hbackdrophcellularhdatatypehdatabase00hdatetimehdbresulthdurationgenvflaggeqbandshepisodes0heqpresethethernethfavorite0hfinishedgglrgba8hgenericshlocalkeyhnotfound00honcelockhorderinghoriginalhplaylist000000hprogress000hqueuedop0hredactedhresolvedhresponse0hspectaidhsubtitlehsyncsinkhtostringhuserdatahuserinfohuserskiphuserstophverified0gaddedatgalbumid0000000000000hattemptshautoplay00gbaseurlgcanseekhcommands0hdeferredhdownload00000hduration000000000000000genddategendtimeggetitem000ggetjsonggetmodehhandlersghasitemghasnext00gimageid00gismutedgitemidshjellyfin0hjellytauhlanguage00gmediaid0hobservedhoverview0000gpinitemhplatformhplaylist0gpollnowhposition00000000000000000000000hprevious0hpriority00hprogress0hprotocolhregisterhregistryhreporter000hresolvedhseekable0hsessions00gsetmodegsetrate00hsettings000hsnapshot00gspanforhteardownhthrottlegtoowned00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000gtrackid0gtryfrom00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000gtryinto00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000husername00hverifiediapphandleibassboostiborrowmutibuffering0hchunklenicancelled0iclassicalicompleted0hdeviceididirecturl0iendreason0ierrorimplierrorkindiforbiddeniformatterhgllineariimagetags0iimagetypeijrayactor0ilocalfileimediaitem00imediakind0imediatype0impvplayerimutexsafeinamedtypeinonevalueipartialeqiplaystateipolicyextiqueuedrowireferenceirenditionirepoerror0isemaphoreiserialize0isubstringitranscodeitransport0iwordstartiabandonediavailablehcachediricontainer000hdeviceid00idownloadshevictlruiexoplayerifavorites0hfilepath0000hfilesize0hgetitems000hgetlimithgettokenhhasframehhasmediahimagetag0hisactive000000hisfolder00hisforced0hispaused0000hisplayed000hisseasonhitemname0000hitemtype0000000hitemsmuthkindrankhlastitemhlocksafehlogfileshmaxwidth0hmimetype000hmoveitemioperation0hparentid000hpersonidhplayitemhpostjsonhqueryone00hqueuedathreadsafeirecursiveireference000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000iremaining0irenditionhrundrain0isanitisedhseasonid000iselection0000iserialize0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000hseriesid0000000hserverid00000000000000hsetlimithsetmuted00hsetqueue0isubtitles0ithresholdithumbnail0htostring0000htrackids0itransientitransport000iunmetered0huserdata0husernamehwifionlyjalbumtrackjartistitemjatomicbooljauthresultjcacheditem0jcastactivejcommandargjcomparablejconnectionjdirectplayjencryptionjendoftrackjepisoderowjequivalent0jfilesystemigltexturejhttpclient0jhttpconfig0ilevelfilejmigrationsimpvhandlejmarkplayed000jmpvbackend0jmutexguardjpartialordjqueryparamjrepeatmode0jrwlocksafejserializer0jserverinfo0jskipactionjsmartcachejstreamkind0jstructured0jsyncconfigicancelledialbumname0000000000000iapphandle00iauthloginibatchsizeiborrowmut00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ibytesusediclearitemicloneinto00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000icontextidicreatedatidbservice00iendreasonjequivalent000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000jexclusions0ifilecountigetconfigigetgenres000igetperson000igetstatusiimagetags0iimagetypeiisdefault00iisenablediisepisodeiislibraryiisplaying00iisrunning0iisshuffle0iisstoppediitemcount0iitemtypesilevelnameilibraryid00ilocalfileimastermacimaxheight000imediatype00impvhandleimpvplayer0impvrender0inullvalue00000000000000000000000000000000000iplaycount00iplayqueueiplaystateiquerymany000ireadqueuejrepository000irequestidisavetokeniserverurl0000isessionid0isetvolume000000isizebytesislavemacsisortorder00istartedatistreamend0istreamurl0000isyncdrain0itargetdirjthumbnails0itotalsizeitrackplayiunpinitemiwritesafejappversion0kaddpositionkauthmanagerkcacheconfig00kchannelitemkcommanditemkdeserializekdrainreportieqgainmaxieqgainminkfunctionargkinvokeerrorkipcresponsekjraycontextkkeepplayingklevelfilterklivechannelkmedialoaded0kmediaplayer0kmediaserverkmediasource000kmediastreamkneedsreauth0knetworktypeknullbackend0kofflineitemkopenrequest0kplayererror0kplayerstate00kpollinghintkprogressivekpushfailurekqueuestatuskscopeobjectksearchscopeksessioninfoktauripluginktransactionktrebleboostkvolumelevelidbservicejprocessingjrepositoryjalbumcountjartistname0000jartworkurljaudiocodec00jaudiotrack00jauthheaderjauthlogoutjclearcachejclearqueuekconversions000kcredentials0jdeleteitemkdeserialize00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000jdevicenamekdiagnostics000jdirectplayjdownloadid000000jdroptargetjdurationms0jformattimejframereadyigetitemidjgetsession0jhascontent0000jhttpclient0000jischeckingjisfavorite0000jisplaybackjitemscountjjellyfinid0jlimitbytesjloggedsizejmarkfailedjmarkplayed000jmarksyncedjmasternamejmaxbitrate0jmaxretries00jmpvbackendjparseleveljpartialcmpjpersontype0jplayernextjplayerplayjplayerseekjplayerstopjplaylistid0000jpositionms0krecoverablejrepeatmode00jreportswapjretrycount00jsearchrankjsearchtermjseasonname00000jseasonrankjseekoffsetjseriesname00000jservername00jsetcontextjsetsessionjslavenamesjsleeptimer0jsmartcache0jstartindex00jstartingatjstreamtype00jtargetlufsjtargetpathitickstoms0jtodatatype00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000jtotalbytes0jtotalitemsjtrackcountktransaction0jvideocodec0000jvideowidthjwithparamslcachedpersonlcapabilitiesldeserializerldirectstreamldownloadinfoldownloadtaskkexcludedidslfavoritesinklimageoptionsljellyfinitemljellyfinuserklivesurfacellegacyplayer0llmssyncgrouplmarkunplayed0lmatchqualitylnetworkerror0lnetworkstatelobservedtimelplaybackinfolplaybackkindlplaybackmode0lplayerstatuslplaylistitemlqueuechanged0lqueuecontext00lqueuemanager0lreloadstream00000lreportersinklresumestream0kservicenamelsearchresultlstatechanged0lstoragestatslsurfacestatelvideobackendlvideosurfacekaccesstoken00000kactivecount00kalbumartist0000kartistitems00kauthmanagerkautoadvance0kbasesecondskbindtexturekcachesubdirkcallbackctxlcapabilities0000kcompletedatlconnectivity0000kcontenttypekcontexttypekcurrenthintkcurrentitemkcurrentmodekcyclerepeat0kdefaulttruekdeletetokenkensurespacekfailedcountkfromcommand00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000kgentextureskgetchannels000kgetsessionskgetupcomingkhasprevious00jindexisduekindexnumber0000kiscontainerkisreachable0kisretryable0kitemscached0kitemspruned0kjellytaulibklastcheckedklibrarynameklistoptionsjloggednoglkmaxepisodeskmediaplayerkmediaserver0kmergedmediaknativevideo0kneedsreauthknetworktype0knextepisode0knormaliseidkpartialpathkpausedcountkpendingseekkplaycommandkplayableurlkplayerpausekprofiletype0kqueuedcount0kreadpendinglrepositorieskresolvepathjsavetofileksearchquerykseriesprefs0ksessionseekkshouldretrykshufflemodekspawneventskstorageinitkstoragetypekstoredleveljteximage2dktotaltracksktrydownloadkvideoheightkvolumelevel0mactivesessionmadvancetonextmaudiosettingsmcachedlibrarymcastdiscoverymclonetouninitmcountdowntick0mdeviceprofilemdownloaderrormdownloadevent0mdownloadstatsmencryptedfilemexcludehiddenkgltexture2dminternalstatemitemsresponselmigration001lmigration002lmigration003lmigration004lmigration005lmigration006lmigration007lmigration008lmigration009lmigration010lmigration011lmigration012lmigration013lmigration014lmigration015lmigration016lmigration017lmigration018lmigration019lmigration020lmigration021lmigration022lmigration023mnameddatatypempanichookinfomplaybackendedmplaybackstart0mplayerbackend0mplaylistentrympreloadresultmqualityoptionmresumetrackermsearchoptionsmsubtitletrack0msyncprocessor0msyncqueueitemmvideosettingsmvolumechanged0lallowattemptlaudiobitratelcalcprogresslcurrentindex000lcurrentmedialdeferredseekkdevicegetidkdevicesetidldisplaytitle0ldownloaditemldownloadrootlensuretargetlerrormessage0leventemitter0000lexecutebatch0lexecutequerylexpectedsizelfromjellyfin0lgetdownloadslgetfavorites000kgetfromfilekgetimageurl000kgetjsonfastlgetlibraries000kgetuserdatalhtml5playingkimagegeturllismonitoringllastdurationllastposition0llegacyplayerkloadandplaylmarkdeferredlmarkfavorite000lmarkreportedlmatchqualitylmediasources00lmediastreams00lmergedvolumekmimefromextlnormalizeurlkonmpvupdatelparallelracelplaybackkindlplaybackmode000lplayertogglelplaylistname00lpremieredate000lprocessqueuelpushfavoritelremotevolumelreportedtimelresolvescopelresumeoffsetlruntimeticks000ksavetocachelsearchpeoplelseasonnumber0lseekabsolutelsetreachablelshouldreportlshuffleorderlstoragelimit0lstreamresumelthreadhandleltracksplayedktrytostring0000lupdateconfiglusingkeyring0lvideobitratelvideosurface0lwithdurationnauthentication00ncontrolcommand0ndownloadresultndownloadworker0neventthrottler0nfunctionresultmglframebuffernhandofftoaudionindexpassguardnjellyfinclient0njellyfinconfignlivestreaminfonnewtrackloadednnowplayingitem0npositionupdate0msecretheadersnsecuritystatusnsessionchanged0nsleeptimermode00nthumbnailcache0ntypecollectionmaudiosettings00000000mclassifyerrormconvertparamsmdeviceprofile00mdownloadalbummdownloadbytes0mdownloadvideomencryptionkeymepisodenumber0mexceedssourcemfavoritesonlylgetcachesizelgetitemcountlgetjsoninnerlgetstreamurllisexcludedbylisinprogresslisitempinnedlisvideomediallastseektimellastsyncedatllivestreamidlloggednosizemmarkcompletedmmarkreachablemmaxconcurrent00lmaxsizebytesmnormalizedurlmofflinesearchmpausedownloadlplayerskiptompositionticks00000000mqualitypreset0mqueryoptional00lqueueforsynclreadlastsyncmredactheadersmreportoutcomemreportsuccesslrunindexpasslruntimeticks0msafecomponentmsavethumbnailmsessionpoller0lsetapphandle0lsetendreasonmsetrepositorylsetserverurlmsourcebitratemspectabuildermstartdownloadmstartpositionmstoptranscodemsubtitletrack00msyncprocessorlsynctoservermtexparameterimthumbnailsavemtoggleshuffle0mverifysessionocredentialerrorocredentialstoreodatabaseservice0odatabasewrapperodiagnosticsinfoodownloadmanagerogetimagerequestogetitemsoptionsohtml5nativeseekolibrarysettingsolocalnativeseekomediarepositoryomergedmediaitemopendingfavoriteoplayitemrequestoplaybackcontext0oplaybackstopped0mretrydelaysmsorusqliteservice0orwlockreadguardnspecialsseasonosessionverifier0osessionsupdated0osleeptimerstate0osmartcachestatsostreamselection0osubtitleprofileothumbnailworker0otryreserveerrormaddtoplaylist000nallowsdownload0nauthinitializenavailableslotsnavailablespacembuildaffilterncanceldownloadmclonetouninit00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ncollectiontype0ncompletedcountmconfinetorootncreateplaylist000ncurrentepisode0ncurrentsessionndeletedownloadndeleteplaylist000ndeletetexturesndownloadseasonndownloadseriesndownloadsourcenequalizerbandsmevictlruasyncnfavoritesscopemforqueueditemmgetauthheadermgetcachedpathmgetdevicename0mgetjrayactors0nimagesemaphoremintomediaitemmisepisodeitemmishtml5activenistransferring0njellyfinclient0000mmedialocalurlmmediasourceid00mnameddatatype00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000nnotimplementednofficialrating000nopengenerationmparsequeuedopmpeekendreasonmplayonsessionmplayqueuefrommplaysessionid000000nplayerpreviousnplaylistcreatenplaylistdeletenplaylistrenamempostjsoninnernproductionyear000nrecordpositionnremainingcountnrenameplaylist000nrenderercodecsnresumedownloadmsavetokeyringnseriesprogress0msetaudiotrack0msetreportsinkmsetsleeptimernshoulddownloadnstopmonitoringmsyncclearusermtakeendreasonntoggleplaybackntranscodingurlnunmarkfavorite000mupdatelocaldbmwithstarttimeA`albumstorageinfoA`autoplaydecision00A`autoplaysettings0A`backendiniterrorA`backgroundactionA`cacheditempersonA`credentialresultA`deserializeownednglunsignedbyteA`hybridrepository0A`jellyfinuserdataA`mediasessiontype00A`mpvrendercontextA`negotiatedsourceA`negotiatedstreamA`onlinerepository0A`playqueuecontext0A`playqueuerequestA`playbackprogressA`playbackreporter0A`playbacksnapshotA`playercontrollerA`publicsysteminfoA`rwlockwriteguardnsecretjsonkeysA`streamingqualityntickspersecond0A`tostringfalliblenvideoitemtypesA`webviewaudioload0njellyfinclientoactivedownloadsnalbumfilenamesnauthgetsessionnauthsetsessionobindframebuffernbuildlogpluginobytesdownloaded00ocommunityrating000oconnectionerrorocountdowncancelocredentialspathndefaulteqbandsndeletefromfilendrainsyncqueuendurationorlastodurationsecondsoenqueuedownloadoerasedserialize0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000nfirstfailureatnformattimelongogaplessplaybackogenframebuffersngetalbumtracksngetfromkeyringngetlatestitems000ngetprocaddressngetqueuestatusngetresumeitems000ngetsubtitleurl000nhtml5isplayingnistruncatedendnisusingkeyringnjellyfinitemid0nlastplayeddate0nlastreporttimeolibrariesfailed0nloggednowindownmapdownloadrowomarkunreachablenmirroruserdataonormalizevolume0nnowplayingitem0nopenlivestream000nplayergetqueuenplayerplayitemnplaylistitemid0npositionorlastnredactembyauthnresumestreamatnsecondstoticksosessionverifiernsetcurrentitemmsetenvifunsetnsetpollinghintosettransferringnstarteventloopostartmonitoringnstarttimeticksnstarttvsessionnstoragegetitemnstoragegetpathnstoragegetsizeostreamselectionnsyncgetpendingnsyncmarkfailedntickstosecondsntotalsizebytes0ntvsessionendedowithoutexcluded00AaaddtoqueuerequestAabackendnativeseekocatalogindexttlAacatalogindexeventAacatalogsyncresultAacatalogsyncstatusAadiagnosticsbundleAadirectplayprofileAadownloaddiskusageAadownloadsresponseAahtml5reloadstreamoindexinprogressomaxsyncattemptsAameaningfulcontentAaofflinerepository0Aaplaytrackscontext0AaplaytracksrequestAaplaybackoperation00Aaplayerstatusevent00Aarepositorymanager0osearcheventnameosecretquerykeysoskipbacksecondsA`streamingqualityAasearchupdateeventAasleeptimerchanged0AasleeptimerexpiredAasmartcachewrapperAataurieventemitter0Aavideoseekresponse0Aavideoseekstrategy0AawaitingfornetworkA`absolutepositionoaudiotrackindexA`autoplaysettings0A`backgroundactionA`backgroundpolicy0obelongstoseriesobuildauthheaderoconnecttoserverA`countdownseconds00ocreatemediaitemA`decrementepisodeodirectstreamurl0oeqfilterentriesA`equalizerenabledoexcludeditemids0A`excludedsnapshotogetplaybackinfo000ogetplayerstatusogetresumemovies000ogetsimilaritems000oisactivesessionolastinsertrowid0olmsunsyncplayer0ologgedfirstdrawomergedisplayingA`needstranscoding0000A`observeddurationoofflinegetitemsoonplaybackendedopercenttovolumeoplaybackmodesetA`playbackreporter00A`playercontrolleroplayergetstatusoplayerplayqueueoplayerseekvideooplayersetvolumeoprimaryimagetag00000000000oracewithrefreshoredactserverurlA`registerdownloadA`registeredhandleA`remainingseconds00A`reportcompletionoreportstoppedatA`repositorycreateA`repositorysearchA`sanitizefilename0osearchcacheonlyosessionspollnowoseteventemitter00osetnetworkstateosleeptimerstateosortseriesorderostoragegetitemsostoragegetusersostoragesaveitemostoragesaveuserA`streamingquality0A`subtitleprofiles0osyncfullcatalogA`thresholdreachedA`throttledurationotransfertolocalousehtml5elementovolumetopercentA`withconnectivityAbauthmanagerwrapperA`catalogindextickA`catalogitemtypesAbconnectivitystatusA`defaulttimeoutmsAbexclusioncandidateAbinvokeresponsebodyA`libraryholdsitemAblockscreenmetadataAbmediaserverwrapperA`normalizereflufsA`normalizerefpeakA`nosubtitlestreamAbnetworkstatehandleAbplaybackreportsinkAbplayereventemitter0AbplayerstatewrapperAbresumequeuedresultAbsharedeventemitterAbtranscodingprofileA`videoplaysessionA`calculatebackoffA`albumplayhistoryAaalreadydownloadedA`applyproberesultA`audiostreamindexA`cachewithtimeoutAacalculateintervalAacalculateprogressA`candownloadasyncA`canstartdownloadA`cancelsleeptimerAacheckreachabilityAacrossfadedurationA`devicetotalbytesAadiagnosticsexportA`durationfromsecsA`emithtml5controlA`emitqueuechangedA`getplaylistitems000A`includeitemtypes0A`installpanichookA`kindfromjellyfin0A`loggedfirstframeA`loggedrenderfailA`maxaudiochannels00A`maxstaticbitrateA`moveplaylistitem000AanegotiateplaybackAanormalizeusernameAapartialcontainersAaplaybackreporting000A`playergetsessionA`playerplaytracksA`playertogglemuteA`playlistadditemsA`playlistgetitemsA`playlistmoveitemAapositionthrottler00A`postjsonresponseA`queuealbumtracksA`redactjsonvaluesA`reporthtml5stateAarepositorydestroyA`requestwithretryorowtocacheditem0osavetocacheimplA`searchserveronlyA`selectaudiotrack00A`servedaudiocodec0A`sessionsetvolumeA`setaudiosettings00000A`setmaxconcurrentA`setsubtitletrack0A`shouldcachealbumA`starttimerthreadA`storagegetpersonAasubtitleswitchingAasupportedcommandsA`testkeyringinnerA`totalrecordcount00A`transfertoremoteA`updateaudiotrackA`useswebviewaudioA`videoaudiocodecsAcaddtrackbyidrequestAcalbumaffinitystatusAcalbumrecommendationAcbackendreloadstreamAcbackgroundaudioplanAacatalogindexeventAcconnectivitymonitorAadefaultmaxretriesAcdownloaditemrequestAcjellyfinmediasourceAcjellyfinmediastreamAcmediasessionmanager0AcnetworkstatewrapperAcplaybackinforequestAcplaybackmodechanged0AcplaybackmodemanagerAcremotesessionstatusAcremotevolumecontrolAasubtitlequerykeysActhumbnailcachestatsAbapplicationversionAbauthreauthenticateAaauthverifysessionAabackdropimagetags00A`baseretrydelaymsAacachedalbumtracksAacatalogsyncstatusAaclaimstreamresumeAaclearreportedtimeAaclearwatchhistory000AaconfinequeuedpathAacurrentintervalmsAacurrentjellyfinidAbdeleteframebuffersAadeletefromkeyringAadetectaudiosystemAadoerasedserialize0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000AadownloadwithretryAbenginecapabilitiesAbenvironmentsummaryAbexponentialbackoffAaforcollectiontypeAaformattimesecondsA`getitemsbyperson000A`isaudioonlyvideo0AaisserverreachableA`lmsgetsyncgroups0AamoviesessionendedAaparentindexnumber000AaplaybackcontextidA`playeraddtoqueueAaplayercyclerepeatAaplayerreportstateAaprunestalecatalog0AapumpdownloadqueueAaranksearchresults0AaredactqueryparamsAaremotesendcommandAaremotesessionseekAarepositorygetitemAaresolveskipactionAasessiontogglemuteAasetjellyfinclientAasetupdatecallbackAastartaudiosessionAastartmoviesessionAastoragedeleteuserAastoragegetserversAastoragemarkplayedAastoragemarksyncedAastoragesavepersonAastoragesaveserverAastoragesetwatchedAasyncmarkcompletedAasyncqueuemutationAatemporaryttlhoursAathumbnailgetstatsAathumbnailsetlimitAbunregisterdownloadAbcandidatescanlimitAccredentialsfilenameAdconnectivityreporterAbdownloadeditemscteAddownloadmanagerstatsAddownloadvideorequestAbfallbackaudiocodecAbglcolorattachment0AdjellyfinplaylistitemAblibrarysettingskeyAdplaybackcapabilitiesAdplaybackinforesponseAdplaybackstartrequestAbskipforwardsecondsAdsessionpollermanagerAdsessionpollerwrapperAdshownextepisodepopup000AdvideosettingswrapperAbwebviewaudiocodecsAbaudiotracklanguageAbchangedfavoriteidsAcconnectivitymonitorAbdecideplaybackkindAbdeletealldownloadsAbdiagnosticsgetinfoAbdirectplayprofilesAbextractjellyfinidsAbgetactivedownloadsAagetaudiostreamurl000AbgetcredentialspathAbgetdownloadeditems0AbgetitemsunfilteredAagetlivetvchannels000Aagetnextupepisodes000Abgetstreamselection0Aagetvideostreamurl0AaisplayingorpausedAblibrarygetsettingsAblibrarysetsettingsAbmarkdownloadfailedAbmergesearchresultsAampvrenderupdatefnAbofflineisavailableAbpanicpayloadstringAbpickcurrentepisodeAbplayablemediatypesAbplaybackmarkplayedAbplaybackpositionmsAaplayermoveinqueueAbqueueprecachecount0Abremovefromplaylist000AbrepositorygetitemsAbresetautoplaycountAaresolveincachedirAbresumeaudiosessionAasavegenrestocacheAbsendgeneralcommandAbsendsessioncommandAbstoragesavelibraryAbstoragesearchitemsAbsupportsdirectplay00Acsupportstranscoding00AbsyncmarkprocessingAbsyncprocesspendingAbthumbnailgetcachedActranscodingprofilesAbuniquetracksplayedAeaddtracksbyidsrequestAcdefaultremotevolumeAefavoriteschangedeventAefunctionresultvariantAbgltexturemagfilterAbgltextureminfilterAblastcatalogsynckeyAcmaxprogressfractionAcminprogressfractionAeplayalbumtrackrequestAeplaylistcreatedresultAeplaylistitemsresponseAcstreamingqualitykeyAeseriesaudiopreferenceAestreamqualityresponse0ActickspermillisecondAethumbnailcachewrapperAcaudiotrackswitchingAbautoopenlivestreamAcbackgroundaudiobaseAcbackgroundaudioplanAcclearstaledownloadsAdconnectivityreporterAccreateplayerbackendAcderiveencryptionkeyAcdiagnosticssetlevelAcenableremotecontrolAcexitbackgroundaudioAcfetchseriesepisodesAcgetdownloadsallowedAcgetrediscoveralbums000AbgetsmartcachestatsAblmscreatesyncgroup0AcloadcredentialsfileAcmaxstreamingbitrate0AcmedialocalselectionAbnotesleeptimerstopAcplaybackcontexttypeAcplaybackmodemanagerAcplaybackreportstartAbplayergeteqpresetsAcplayerrecoverstreamAcplayertoggleshuffleAcplaylistremoveitemsAcreclaimexpiredasyncAcreporthtml5positionAcreportplaybackstart0000AcrepositorygetgenresAcrepositorygetpersonAcsavecredentialsfileAcselectsubtitletrack00AcsetautoplaysettingsAbsetexcludeditemidsAcsetplaybackreporterAcsetstreamingqualityAcshouldprecachequeueAcspawncatalogindexerAcspawndownloadworkerAcspawnfavoritesdrainAcstoragedeleteserverAcstoragegetlibrariesAcsubtitlestreamindexAcsupportsnativevideoActhumbnailclearcacheActhumbnaildeleteitemAfcreateplaylistresponseAfcredentialstorewrapperAfdownloadmanagerwrapperAdincludecatalogbrowseAfnetworkstatewrapperargAdofflineconfirmwindowAfplaybackstoppedrequestAfsessionverifierwrapperAfthumbnaildownloaderrorAdalbumaffinityenabledAdaudioforcestranscodeAdaudiosessioninactiveAcauthconnecttoserverAdauthstopverificationAcautoplaymaxepisodesAcautoplaynextepisodeAdautoplayepisodecountAcbuildftsprefixqueryAcbuildnextupendpointAdcomputedownloadstatsAddeletealbumdownloadsAddeletedownloadsunderAdenterbackgroundaudioAdframebuffertexture2dAdgenerateshuffleorderAcgetalbumplayhistoryAcgetsmartcacheconfigAcgetvideodownloadurl000AdincludecatalogbrowseAdnormalizefilterentryAdplaybackreporterinitAbplayeraddtrackbyidAdplayerdismisssessionAcplayergetsleeptimerAdplayerreportpositionAcplayersetaudiotrackAcplayersetsleeptimerAdpushremotelockscreenAdqueueprecacheenabledAcremoteplayonsessionAdreportnetworkfailureAcsetqueuewithcontextAdshouldshowminiplayerAcspawnsyncqueuedrainAdsupportsdirectstream00AdsupportsmediacontrolAdsynccleanupcompletedAcsyncgetpendingcountAdtestkeyringavailableAdwithcountdownclampedAdwithcrossfadeclampedAgconnectivitychangeeventAefallbackaudiochannelsAefavoriteschangedeventAeglframebuffercompleteAgplaybackprogressrequestAgplaybackreporterwrapperAdretrycheckintervalmsAdalbumprimaryimagetagAeauthstartverificationAebackgroundaudioactiveAebackgroundaudiosourceAdcacheitemsfromserverAeconnectivitygetstatusAddownloaditemandstartAedrainpendingfavoritesAeemitserverreconnectedAeenqueuevideodownloadsAdgetdownloaddiskusage0Adlmsdissolvesyncgroup0AemarkdownloadcompletedAdonvideoplaybackendedAeplaybackpositionticks0AeplaybackreportstoppedAeplayerdisablejellyfinAdplayergetcacheconfigAeplayergetcapabilitiesAdplayerlocalmediapathAdplayerplayalbumtrackAeplayerpreloadupcomingAdplayersetcacheconfigAereportplaybackstopped0000AerepositorygetchannelsAeresolvecurrentepisodeAeresumequeueddownloadsAdsavelibrariestocacheAdsetshowservercatalogAdstoragegetactiveuserAdstoragegetitempeopleAdstoragesetactiveuserAestoragetogglefavoriteAdsubtitleforcesburninAesupportsremotecontrolAdtransfertolocalinnerAdtvsessionnextepisodeAhaudiotrackswitchresponse0AfimagedownloadsemaphoreAeresumebackoffstepsecsAfresumethresholdsecondsAhrepositorymanagerwrapper0AesyncqueuechangedeventAhsessionverificationevent0AfverificationintervalmsAeadoptvideoplaysessionAfalbumaffinitythresholdAebeginvideoplaysessionAfbuildfavoritesendpointAebuildgetitemsendpointAecheckforlocaldownloadAfcheckframebufferstatusAeclampmaxaudiochannelsAeconverttickstosecondsAecurrentisaudioepisodeAeemitbackendinitfailedAfemitconnectivitychangeAeemitsleeptimerchangedAfenqueueplaybackstoppedAeformattimesecondslongAfgetdownloadedlibraries0AegetfavoritescacheonlyAfincrementautoplaycountAemayberunscheduledpassAfpersistlibrarysettingsAeplayselectiononremoteAfplaybackreportprogressAdplayeraddtracksbyidsAfplayerbackgroundactionAeplayeronplaybackendedAeplayerplaynextepisodeAeplayerremovefromqueueAepostjsonresponseinnerAfrecoverableerrorresumeAfreportplaybackprogress0000AfrepositorygetfavoritesAerepositorygetimageurlAfrepositorygetlibrariesAfrepositorymarkfavoriteAeresolvelocalmediapathAfrestorelibrarysettingsAfsetconnectivitymonitorAeshouldshowvideoplayerAfstartautoplaycountdownAestoragegetaccesstokenAestoragesaveitempeopleAetransfertoremoteinnerAetvsessionepisodeendedAfupdateremainingsecondsAewebviewcandecodeaudioAfwithvalidatedcountdownAfcatalogindexfirstcheckAgexternalsubtitleformatsAgplaybackqualityoverrideAiremotedisconnectrequestedAfaudiotrackdisplaytitleAecacheditemtomediaitemAgcancelautoplaycountdownAgconnectivitycheckserverAfconvertpercenttovolumeAfgetalbumaffinitystatusAggetalbumrecommendationsAfgetfavoritesserveronlyAfgetrecentlyplayedaudio000AgpersiststreamingqualityAfplaybackmodegetcurrentCg[15,\"jellytau_lib::commands::player::PlayQueueContext\"]C`[15,\"jellytau_lib::player::media::QueueContext\"]Cf[15,\"jellytau_lib::player::session::MediaSessionType\"]Ab[10,\"core::clone\"]A`[10,\"core::fmt\"]Be[5,\"core::io::util\",\"core::io::util\"]Bo[15,\"jellytau_lib::player::state::PlayerState\"]Cf[15,\"jellytau_lib::player::events::PlayerStatusEvent\"]Be[5,\"reqwest::error\",\"reqwest::error\"]Bk[5,\"serde_json::error\",\"serde_json::error\"]Ba[6,\"tauri::error\",\"tauri::error\"]Bo[5,\"erased_serde::error\",\"erased_serde::error\"]Dg[5,\"jellytau_lib::repository::types\",\"jellytau_lib::repository::types\"]Bo[15,\"jellytau_lib::player::media::MediaSource\"];Ci[5,\"std::sync::poison::mutex\",\"std::sync::poison::mutex\"]Bm[5,\"tokio::sync::mutex\",\"tokio::sync::mutex\"]Dm[6,\"jellytau_lib::player::media_player\",\"jellytau_lib::player::media_player\"]Dk[5,\"jellytau_lib::storage::db_service\",\"jellytau_lib::storage::db_service\"]Bb[10,\"core::marker\",\"core::marker\"]Dd[15,\"jellytau_lib::playback_reporting::reporter::PlaybackOperation\"]Ba[5,\"tauri::state\",\"tauri::state\"]Ce[6,\"rusqlite::types::value\",\"rusqlite::types::value\"]Bk[6,\"serde_json::value\",\"serde_json::value\"]Cg[6,\"tauri_utils::acl::value\",\"tauri_utils::acl::value\"]f[1,\"\"]Ba[8,\"std::os::raw\",\"std::os::raw\"]Al[2,\"jellytau_lib::download\"]Am[2,\"jellytau_lib::thumbnail\"]Aj[2,\"jellytau_lib::domain\"]Aj[2,\"jellytau_lib::player\"]Bd[2,\"jellytau_lib::commands::player\"]1616Al[2,\"jellytau_lib::jellyfin\"]An[2,\"jellytau_lib::repository\"]8Ab[2,\"jellytau_lib\"]Ac[10,\"core::borrow\"]Co[5,\"reqwest::async_impl::client\",\"reqwest::async_impl::client\"]Ch[15,\"jellytau_lib::commands::player::PlayTracksContext\"]Cd[15,\"jellytau_lib::download::events::DownloadEvent\"]Al[10,\"core::ops\",\"core::ops\"]Cb[10,\"core::future::future\",\"core::future::future\"]Bk[5,\"tiny_http::common\",\"tiny_http::common\"]Ch[15,\"jellytau_lib::commands::player::VideoSeekResponse\"]Co[15,\"jellytau_lib::commands::player::AudioTrackSwitchResponse\"]Cl[15,\"jellytau_lib::commands::player::StreamQualityResponse\"]Ba[6,\"core::option\",\"core::option\"]g[17,\"\"]Di[6,\"jellytau_lib::player::mpv_player\",\"jellytau_lib::player::mpv_player\"]9Bo[15,\"jellytau_lib::player::state::PlayerState\"]Dg[5,\"jellytau_lib::repository::types\",\"jellytau_lib::repository::types\"]E`[10,\"tower_http::follow_redirect::policy\",\"tower_http::follow_redirect::policy\"];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\"]Dn[10,\"jellytau_lib::player::media_player\",\"jellytau_lib::player::media_player\"]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\"]Dm[5,\"jellytau_lib::player::media_player\",\"jellytau_lib::player::media_player\"]2Ba[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\"]Dm[5,\"jellytau_lib::player::media_player\",\"jellytau_lib::player::media_player\"]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\"]Do[5,\"jellytau_lib::player::legacy_player\",\"jellytau_lib::player::legacy_player\"]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\"]9Em[6,\"jellytau_lib::repository::stream_selection\",\"jellytau_lib::repository::stream_selection\"]Ba[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\"]Do[5,\"jellytau_lib::player::video_surface\",\"jellytau_lib::player::video_surface\"]De[6,\"jellytau_lib::commands::player\",\"jellytau_lib::commands::player\"]1Ab[2,\"jellytau_lib\"]Al[2,\"jellytau_lib::commands\"]f[3,\"\"]Aj[2,\"jellytau_lib::player\"]30Be[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\"]Di[5,\"jellytau_lib::repository::online\",\"jellytau_lib::repository::online\"]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\"]?Eb[10,\"jellytau_lib::repository::exclusions\",\"jellytau_lib::repository::exclusions\"]Dk[5,\"jellytau_lib::player::mpv_backend\",\"jellytau_lib::player::mpv_backend\"]5Cg[5,\"specta::datatype::named\",\"specta::datatype::named\"]Am[5,\"std::panic\",\"std::panic\"]Cc[15,\"jellytau_lib::commands::sync_drain::QueuedOp\"]Dd[10,\"jellytau_lib::player::backend\",\"jellytau_lib::player::backend\"]Dg[5,\"jellytau_lib::repository::types\",\"jellytau_lib::repository::types\"]De[5,\"jellytau_lib::commands::player\",\"jellytau_lib::commands::player\"]Em[5,\"jellytau_lib::repository::stream_selection\",\"jellytau_lib::repository::stream_selection\"]Di[5,\"jellytau_lib::player::stream_end\",\"jellytau_lib::player::stream_end\"]3Co[5,\"jellytau_lib::player::media\",\"jellytau_lib::player::media\"]Fi[5,\"jellytau_lib::playback_reporting::sync_processor\",\"jellytau_lib::playback_reporting::sync_processor\"]Da[5,\"jellytau_lib::commands::sync\",\"jellytau_lib::commands::sync\"]Ce[5,\"jellytau_lib::settings\",\"jellytau_lib::settings\"]Cf[15,\"jellytau_lib::player::events::PlayerStatusEvent\"]Aj[2,\"jellytau_lib::domain\"]Aj[2,\"jellytau_lib::player\"]Ab[2,\"jellytau_lib\"]Al[2,\"jellytau_lib::commands\"]2Ca[15,\"jellytau_lib::repository::types::RepoError\"]5De[5,\"jellytau_lib::download::worker\",\"jellytau_lib::download::worker\"]0Em[5,\"jellytau_lib::playback_reporting::throttle\",\"jellytau_lib::playback_reporting::throttle\"]Ao[10,\"specta::function::result\"]Dg[5,\"jellytau_lib::commands::catalog\",\"jellytau_lib::commands::catalog\"]De[5,\"jellytau_lib::jellyfin::client\",\"jellytau_lib::jellyfin::client\"]Dc[5,\"jellytau_lib::jellyfin::types\",\"jellytau_lib::jellyfin::types\"]Dg[5,\"jellytau_lib::repository::types\",\"jellytau_lib::repository::types\"]2Di[5,\"jellytau_lib::commands::download\",\"jellytau_lib::commands::download\"]55Dc[5,\"jellytau_lib::player::session\",\"jellytau_lib::player::session\"]16Cf[15,\"jellytau_lib::player::events::PlayerStatusEvent\"]Co[5,\"jellytau_lib::playback_mode\",\"jellytau_lib::playback_mode\"]Ec[5,\"jellytau_lib::commands::playback_mode\",\"jellytau_lib::commands::playback_mode\"]D`[10,\"jellytau_lib::playback_mode\",\"jellytau_lib::playback_mode\"]Cg[5,\"jellytau_lib::thumbnail\",\"jellytau_lib::thumbnail\"]Cm[5,\"jellytau_lib::connectivity\",\"jellytau_lib::connectivity\"]77<>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\"]87=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..e11af66f --- /dev/null +++ b/api/search.index/root.js @@ -0,0 +1 @@ +rr_('{"normalizedName":{"I":"IcAAAMgY0hhRwAAATgHgBwkNxxjRGCUBwAAAESQLMsYY0Bg1AcAAAEBiAiID0APZBzTOA8UYzxhxwAAAYgIiA9AD2QffB8MYzRg1AcAAADBhAiEDzwMywhjMGNYYdfzADyAAADALCSQL0xgQ1BgQ1RgQ1hgwqBi9GBEfYQIhA88D3gfBGMsY1RhF/MD/AwAAIAoJyRgQyhgQyxgQzBgQzRgQzhggzgPPGBDQGBDRGBDSGN0HwBjKGNQYBQLAAACqXR0ZdBlvcjUBwAAAGlwfcv0eGR9cIKHHAAAIGvsCZGVBygAAdRl2GVEcUhwBQTswAAABAAD2AAIAOBAAAEQS9QArlMcAAB8npcLDAGAAABj1GRMkEhmWGysJEMNhZmUAANIUEoJ0ZQEBwwAAG5ZzZxKAAoGgAAABZgQzscgtaXkxwwAA8xSLGIwYFYHGAAAS/h6KHSMEAsQAAB0ZaHfynxcAAFsFocIAAJ0X8gFyAAFyghgLAADiaBkAAAEaAADyHgAAEQDSAIL9IAAOxQAAHyXBAAAVEgDjKgsAAMELAACfHAAAsiAAAEEAEMVzdGFydAAAaxD6gQIAZGshwwAASxz/HgUDwQAAGb8fInMdvx6nZR9haXIFAsEAABToHCJcHR8eZnQQ0WFsaWRhdGVkY291bnRkb3duAACiHxDIdGFydHRpbWUAAHUbEMVhcmFtcwAAihcQ02VydmVyY2hvc2Vuc3VidGl0bGUAAJAgsMd4Y2x1ZGVkAAByHPKCdXQCAGVzENJxdWFsaXplcm5vcm1hbGlzZWQAAM4fEMd1cmF0aW9uAABhGRDOb3NzZmFkZWNsYW1wZWQAADEfEM1udGRvd25jbGFtcGVkAAAwHxDIZWN0aXZpdHkAAAsdEoABAcIAAB8mbnTygAIAbnXygAIAb3L2D4D+AAcwCQLFAAAdJ3J08oABAWhjFIjBaQAAE25sef8R9gMAQWgB+QgAAOG0EAAAiRcAABAQwmV0AAAXC/IAAgBndACCxBwAAPKAwAyAAW4ViMAAAB+yIMseEM1hbmRlY29kZWF1ZGlvAAChH6DDb2FkAACdGxDFb2RlY3MAAGge8gR1ZGlvAgBjbAABex0AAPIEdmlldwIAYWMB9AwAAADygAEBYmQVAsMAABVoHqOdG2Nsex0FAsEAABFMHlaeHZ8doB2hHYsgZWgbAsMAABjrY3QFAsAAABdgHh2zIGRlEM50aW5nZm9ybmV0d29yawAAnxzWAYCE/QCADeAAABZfAAD9EUEBwQLPOgiU5nll9ylnZUpd/eL9IMAHSad5gm3hg8wAAB+hXvq7qOMmpDXfyyzqbrK+Fv/6ecZW5YwocFdATM9wyQAAH5oAOzAAAAEAAC0BEABjAQAAKQMjAHYEAADEBwAA/AgAADkJAQBFCfUA9AwAAHYNBAB/DQAAXxAAAA0SAABLHAEAyBwAAPYcAAD/HgAAocIAADkJBQLAAAATcRwanx9ucBMFAskAAB9kZW4BZxwAAPsCZWilAsUAABaIGxeLHmN1eRQxxQAAWBB3FHgUIcUAABgSdhRRxwAAeh57HnwefR6XIDHGAAAdEh4SSxYAQmlwOzAAAAEAAPYAAgD1AwAAyQb1AAABgoJunhuEsgUCwQAAFOEUGxIdZXIQyG9wZXJjZW50AAAKHTDEZXZlbAAACBWsGK0YBQLAAAAUWyAkkB1jIG1uoMVhbmdlZAAA6RjygAGIaPIDdW1lwAKBAIoNCwAA6bYUAAAtGQAAQRkAAE8cAADSHAAA2xwAAGYdAACuHgAAqx8AAMAfAABgAQAAABDCYWwAAPsHtgGAAgPAAAAKPGNsaWtyCQFqXrE7Q3lhwgAAmA2ZDWwYbRhuGFIbBQLGAAAgQh+hIGIVHxYfFx8YH1ogpCBzdSuKxQAAILMQxGlkdGgAAIkXMccAAIQehR6aIDDFcmZhY2UAAL4XXxlgGRDGcmFwcGVyAABnHvIFdGluZ3MBAHcB6BgAANK6HwAABABHdHJhdGVneYKdHAAAgoAgAACgx2VzcG9uc2UAAJsc8oFrAgByc/KAAgBldPKAkgF0AEZlc3Npb24BIR0AANJwHwAAAhKDbGF5AQLHAAAfSMAAAB+ac2JlEMh0ZW10eXBlcwAAnBsQxWVpZ2h0AACrGNDEb2RlYwAAhBcEhcAAABVja2VuZL0XFXRyYXRlXhkQynVkaW9jb2RlY3MAAHwd9h8CZW/9h4ESwAQiwwAAGaHFAAAeWOH5BwAA+AgAABDniBgAAJQZAADaHAAAKR4AAPceAACpIAAAtiAAALggAAAQAQQAFgWAAf00QAVymFUeqS3HJwFhxAAAHGHMAAAeIWQ1AcQAABpaIGSIGIkY9x4xwwAAAB1dH18fUcgAAHoeex58Hn0elyAFAsEAABCuFBRjG255FYTEAAATXx1JGVvAigDCAAAYnWsDxgAAGcdob3bFAcoAABd8IHNDHQkCzwAAIIXCAAAeSm5yawPBAAAYnGFlbAUCxAAAGVsfEKAeaXkFAsIAABAWEhLlFGV0MwD9IGEAwgAAFGnLAAAfl/ewCAAAWAxEAVsCBQABABoGNQLCAAAVnR8Q3h5wc7oQ/xxTHQUCwAAAF5seFJIeY3H7Am50BQLDAADHlR4Uix9wcQUCwAAAG8YeEwMabnI1/cAJQAQAAKlkHRfPHCegG3EeE6AXIA11DaYNMcAAALIYXiCxINHDAAB2DQFC468UAADOGQAA6RwAAGcgAABAAAWDwAAAFI0ZHasfGwLMAAAfeHB0QcIAAKUftR+GIJIgBQLAAAAWKx4YQR9hY/uGIcMAAGAdrh+xwwAAcxYFAsAAABVcHxyQIGFoUcQAAMwezR7OHs8enyAVgsIAABqNH1sW+wJibAACb24AAeMAABUdc4PxDAAA4dMUAACTHQAAEBIAhAHDAAAe3HQAAUceAAChwgAAJxIAR3Nlc3Npb24B3RkAAAGVHQAANQHAAAAWAR939xn4GV8cMMAAACsNLA01EPKAAQFkcqHEAABtHxDJbnRlcnZhbG1zAABvH/IFYXRpb26EggDSBx8AADPygAIAY2XygAIAaXlSgAEC7gAAEdRmZG72vwAC/kABmF7FAAAdFWlzAOkyCQAA6xQAAIUYAADYGQAAqRwAAEseAAD0HgAABh8AAD0fAACqHwAAIBAAAACxwwAAxRciAAD9gEAJxAAAFObMAAAe6MsAAB9ZAWUBAAAAAAAC/gBeLg8hYQLxvSJan2bRWUfWDbtYJlLLtCatE+EnywAAHp1toPkHHnfu3g9twgAAFL4k3HdWu00OIZbif1Ofa8YimNDqCQ5tAi3VfaHsDGxjcgDrDQkAAPMKAADTCwAAjBAAAMkQAAAGEgAA/xQAANoZAAC6GgAABR8AADsfAABSIAAAYAYAAAAAFYHGAAAYpiByIPMFggGvGAAA8wIBdOEaCwAA0RcAAAEFAsAAABMLGhMMGmxwEwUCywAAIHRzdvNtEAAAHw6jADHBAACjHTgfbx8BQjswAAABAAB9AAIADhIBAMwTewAAAWUAAeAAAB1Zc4P2BwAAOzAAAAEAACQAAgBbEAAAexQjALIAkAPNAAAfomVpbQABKB0AANKAAf0BIQHJAAAgdMMAAB6wbAAA/RFBgP2mgAt64MRchUHVw+vmR1A13Kd7jThQdFxbFt/EQXmFo5MuyLPBEMIAAA0AKYNyHtRVjJBCaEjCWCJNG70w+djEAAAZMADhCAAAACQDAAADFYTAAAAhBRWlG8oHBZTAAADGVxujLRIhwwAA6AvCGVvAIoDgAAAI76HCAAAXCaDCbHMAAPYIAAAAiMEAABnBAWQBAAAANggAAv2R0ArDAAAeTnYPcDhPELbUlC7AAAAdECY26mm+0gAAIJDBAAAJCGNpADswAAABAAD+AAkAKAMAABcJAQDdCgAA6gsAADgQAACvEAAARBL1ABoVAAAnHQAAwcIAAP8IocsAAPMZBQLBAAAUABUVQBlpdiMAA8AAAArszgAAHyJlZnIBqQYAAEHCAAB9HEMfRB+nIBUByAAAEJwgcxEdE4EC4QAAFEhjcwBIZ2tleXJpbmeCXBkAAAFDHAAAkoACAooAAAj14AAAB+RuemNvscMAAIscAQACwwAAGJHFAAAZrmNv4xkJAACYEAAAOhMAAIYeAABEABDMd2Vidmlld2F1ZGlvAAB7HRDCb3AAACoNGNwAACkN8gACAGt0AAH+HAAAAENhbWXhMhAAAP4RAAAgAUgdAAAQwmZvAAAoDSKAAgCtAAAM42RuAENhdGHhJw0AAPwRAAAB8xcZAABtAsoA8gD9CCECAOH0AwAAxgYAAAL2lRcAANcDlAFGARcBAgAQy3RtbDVlbGVtZW50AAAJHfIAwCBgAmR2AOLqBwAAwggAAJUYAAAGAPIAhgRhaHF0ADowAAABAAAAAAAZABAAAAAGCwcLCAuYDZkNVBQBFaUXbBhtGG4YehlSGzgcehy6HNgcDx1+HY8dmx0+H3YfrB9vIJMgFYTAAAASyRBPHgGCASbuQbrIdAUDwAAAq5kcFQUVJ3oZbyBlcHRFAcMAABS8F3OWF74XXxlgGaHBAAAiFiME/SBQAsYAAB0xxAAAG7PFAAAbtOb/DAAASw0AABsZAABhGQAAzxwAAAcdAAAuHQAADwAAAHHEAACpIKogqyCsIK0gtiC4IPICb3IAAXYB4gwAAAASAAEB4AAAIIVmc4bRAwAAOjAAAAEAAAAAADIAEAAAAEANQhBDEK8UsBSxFLIUsxTFFMYUxxTIFMkUEhWUGA4ZDxkQGREZGRkvGZ0ZrBnOGcIabxs9HD4cPxxAHK8csBzpHHIecx50HnUehB6FHhUfFh8XHxgflB/MH1ogZyBtIJUgmiCkIDYbgAH9NSEDRwwk8RcPJCO1vsEAAB9hbAUCwAAAFgwfVn4efx6AHoEemCBucAUCwgAAImsYUR8VLR9pcAUCxwAAFpsgF6IgY3AQz21hcnRjYWNoZWNvbmZpZwAAzR8Qz2VtYWluaW5nc2Vjb25kcwAAoB8EAsJvYwAAE2xkYnQbHnNjcmVlbm1ldGFkYXRhYCBhaxDOcnJlbnRzdHJlYW11cmwAAMwfEMRuZmlnAABbGTKAmIHFAAAeQxDJdWRpb3RyYWNrAAB6HfYRA2F0Zf0FCAP9OIAA4QAAGfnDAAAclMAAAB6LAOEwGQAAdxkAAAF2AAAB/RSIAsAAAAjwQNwNq//hAAAeLGQA45kXAABjHgAA0R4AAEUfAAADEQX9wBQBQAAAG4MfEWEcGIEbqMYcocMAAGEUBQLAAACgfBirNx5kcxUBxQAAFrAedmwZI4MCwQAAFOBhcgUCxQAAHaMgHJEgcnQjgwLBAAAcQmVz+wJhdCHGAABNHH4gEM1pc3RlcmRvd25sb2FkAABSHvqSAYFnocQAAJoXEMVuaXRlbQAA0RTygIQBbKDFdGVyZWQAAPoRAEpya2Zhdm9yaXRlhHAbAAABWSAAAPKAgwAQw293bgAARQsSgAEBwQAAEDtubAFBOzAAAAEAAIoAAgCzGAAADxqJACsCxQAAGcNkacHGAACpG/IAAAF5AcUGAAAAEM51ZXRyYWNrc3BsYXllZAAArB7ygAIBcXRu9msA/QCVAf08IAYX1MgZh8YAAB55cN/AcQwAAU0DAAAAAAn9fxkghQAAA86BAAADz4MAAAPQoAAAAU5Da91DVlymSJxybG+9yvsdhOCAyl8QS6x5a3JMDIVt4y3CQhRNqovkNdxF+uliMrAt/mq+bNyfKGEsBItB2WL9YxXN1KbRWPeBom/UAAAgszEzNjhucHJzdADybw0AAAgDIcAAAF8C1hc1wsECQAAAFjMdFTgcGiYf5xHoEVccBQLBAAATkRwVPB5rcPOCAnJ1UcsAAH4efx6AHoEemCAVAsQAABYlHxT6HGZzsiD7AnR4AULiDRUAADgZAACQHAAAQgD7gwWFxgAAGC8fFHUdBQLAAABTTBRNFE4UTxTmHhVSHW50GwLgAAALlWVsFQHAAAASPRZ04wslAcoAABPIH2LJHnUgscwAAFMgU4IDywAAH3pkZWf7AmF5BQLAAAAbfyBSehkPHX4drB9vIHJ0G4nKAAAgcysCyQAAIGdldAUCwAAANVUexB7JH6W7HnBzwcQAAKkb+4NBxgAApR+1H4YgkiAVAcQAABlXIHPgGPsCcHY1AcUAABbQH3eDHWsemR8FAsIAABhmIBp+IHJ1+4kVgsMAABQyH3sfE4GDxwAAH6qhxAAAuRkAQITxCAAA9mgUAAA0B3IBKgABAFYBAEFkOzAAAAEAAPYAAgA7CQAA7Av1AAAQyW9sbGVjdGlvbgAAghlyAAMBzQAAIKFjaXNkAfMDAAA7MAAAAQAAFQIXAPkMAABNEAAAVhABAFoQAACnEAcAthADAFcUAABzFAIA8xQAAE4WAQCBFgIAiBb1AOIXAQCLGAEAlhgBAKYYAADXGAAAmRoBAMYaegCFGwIAIx4AANgeAADVH3oA8oCCAXI2HgAB/VRUB8UAAB8DxwAAHz7BAAAeuccAAB9xcAA6MAAAAQAAAAAAGQAQAAAAyweZCM8L0AvRC5oNmw2cDZwX3RfeF98X4BfhFyYZmBuTHAEdAh0LHS0d7x6zH7wfxR9oIAFE47YUAADbHAAAZh0AAMAfAABgAAUCwAAAJrofvh83hB6FHpogZXRRygAAFR8WHxcfGB+kIFODwARIyQAAH3Cgwm93AAA5CRDKZXh0ZXBpc29kZQAAZB8EAsAAABNkZWRxHBppc29kZWVuZGVknx9ucPIFc3Npb24CAGVuAAFnHAAA8oACAGVoVAMAAf0VQQAAxAAAHrDfyQAAHzoFCHMBCAAAAAAxwAAAQhBDEBkZIcAAAGUYqRoFhcAAABexHheyHiHBAAC9GBEf0wAE4QAAEi0yZG1z9AgJAADhAigGwwX7AmVsgIAB/ABwigAACPDEAAAYa8oAAB5DXGwygAECO85vZylwcnNBwAAAhguHC1sdth+hwgAAxBxlAsAAAKZlGSKUG9ccY2wyHZAdyB4sH2IfYyD7ygABAQEBxgAAG5tm5TYJAAAmDQAAZxAAAO4RAABWGQAAjR4AABAEQFHBAABIFkkWShaxHIQd+wJlbgUCwAAAHTgfs2AWaG+hwwAAeBjQx29zdHJpbmcAAFYZEMhydmVlcnJvcgAAAxoSgmVzggHJAAAgd3QAQ250bzswAAABAAD2AAIARAsAADwP9QAAAENyb207MAAAAQAA9gACAEMLAABGDvUAAAUCwwAAEZkdEvIZbXMQxndubG9hZAAAqhjygIiC8gD9KAEFAmFjAPahDQAAAQs8AH4EwwD7AKHFAAAtEgQCxXRyZWFtAAAdZXN1bWVwb3NpdGlvbqMgHG9sZXJhbmNlc2Vjc5EgcnQShWNhdGVkAYLBAAAcQnMygAEC4AAAF+luY2UFAsIAAFQ5HDocOxw8HGwgVbscvBy9HL4ceiBpbSsCwwAAHvpwdSWCxAAAEaYfXx5hIAUCwQAAE2gbE0gcc3QTgQLEAAAZzXJzBQLBAAAVkyAVrh5zdhOBAsUAAB7edHZRzQAAzB7NHs4ezx6fIFHQAACvH7AfsR+yH7QgEMdsZWJvb3N0AAAHFdYdgAH+QANsAT1KSRbvwgAAG1ViQcEAAJ0cnhyAIIEg8wEBZ+dSCwAArQ0AAEYWAADsGAAAXhkAAM8ZAABCHQAA1B4AACIQAAFgw29ydAAAaRBqEPYR9xH4EfkREMNlbnQAAPURUcMAALsavBpjHIMgiCAQxG5uZXIAAJ4f8gVlbW90ZYQAAXkdAAABnSAAABjBAABjH/IEb2NhbIQAAQgdAAABlCAAAPKBbwIAbHLygmVyAQF0chDCcmwAAG8bFAHGcm9maWxlAAAQqx5zIB3yAm5nAgBwdQDhyhwAAKUeAABCAAAAAcYAAB/GZAFoEAAA8tMZAAAxBfKCb2SGADDFY3Rpb24AAAYVgheDF/KBc/0lgQAABYnAAAAWaR4UdBkVAcMAABZ9HXIcHwUCxAAAEr8ep2UfaXKhxgAAhRwVAcQAABatHnKHHwBFbGF5ZWQBVRkAAAGsHgAAdgEAAcIDIMUAAByHcAD1qRgAAIgDHgEJAEIAEMNsYXkAANAUpAHAAACg8xFzRA4SgAEBwgAAHKFkbhDEb3VudAAAgRf2CQD9BIEC/RoIAcoAAB+nwAAAGOHFAAAetgH1BwAA7QASAAAMEgAAFhUAAOMYAADUGQAAZhsAAGEdAABuHQAAeh0AAOkeAAAgHwAAUB8AAL0fAAC/HwAAACEhIQIAABKAAQHKAAAecGt08oACAWNudPIA/REASIwA8ssDAAB4BSHFAADSHKsfFQHEAAAWdRxyNB7xA4LWAa8YAADFA8AAACe4GqgfFQISoIYWZWlz/wglhMMAABRjHwgdlCArisgAACCRRQLDAACkXR0TXx1kaUkZnR5bH3wgG4nAAAANn6UBxQAAHHIgdNEXGwLCAAAZbmR1AUQ7MAAAAQAAigACALMYAAAPGokAIoABAo0AAAjvwwAAGcFjbnQQxXJhY2tzAACpGBQBw2l6ZQAApHl0ZXNvHGLPFLDKZWNvcmRjb3VudAAAdh0QxHRlbXMAAIAXAER5dGVzgn4XAAABLx0AAPYBAWz9AgEHAc8AACCCZAHuCAAAABKAgYTCAAAcXgUCwAAAEqccMtgZqRwGH2l2IcQAAG4ceB/7AmNyEMdhbGxpYmxlAACbG/IEcmluZwEAZuEmDQAA7hEAAASFVhkAAPKAAYJ0AENuZWQ7MAAAAQAAigACAEILAAC6DYkAABKAAQHLAAAewHdwAEAB7QgAAOSHEAAArhQAAL8XAADqFwAAnB8AAABQABqtAQHDAAAbY255McMAAFAdQR5YIABGaHVmZmxlgtsZAAAB3R4AABDHbGF5YmFjawAAbhuyA2dsZQICxgAAH2Bwc2ZtAAE3GQAAAEdhdGF0eXBlOzAAAAEAAPUAAQCIFvUAAAAA/UhEDv5ACe4IWDWsXHpc4dUjwAfo0tn8TZel489REey/5TTExTBKOGI5fU54OkAbDj7zknpJwgAAGJ07D1WZuHrruxQeTA2z/pgrDKoqykxWFrljUIDb0MhvF4oDqAA7MAAAAQAAewIJAOEDAAB4BfUAMAkAAEQLAADUCwAA4QsAADwP9QA8E4kArxkAALHDAADkEQWGwAAAFFMdGEUeIccAAGUcfyABQeRiDQAArA0AACISAABcFgAA5hcAAAASAPvBBAQVAcMAABVWIGzyGBUByQAAGH0gZG0dE4ECxQAAHYRjchDDYmlkAADrC3YFgAH9EUgKecsUlUngAAAdEGShyQAA8xzRwwAADRUVAcMAALDNF3MBEjMAA+YAAAtHaW5z4voIAAA/CQAAyBcAAANASwPKAAAfecAAABFLYWV0CQFoxuvnD26hwQAA9ggBQeWMFwAA1xcAACAeAABdHgAATh8AAIogAAAEAAABQOW9EAAAxhMAAK0UAABUFgAA9BgAADMeAAAwBBAJATcbxbF4bVHCAAA9HD4cPxxAHG0gAUTk4xgAANQZAABuHQAA6R4AAL0fAAASEgAVhMQAACT2HokgGB0TAAPVAAAgscYAAB1zdHV3gukRAAAVAcYAAKADHXMAGgUCwgAAGGEfE6QfY22+AwUA/gAIYAfHAAAdkYTpCAAA9SQNAADSCwEAsAbpAPKAAYJsBQLCAAAW5RlSDhkPGRAZERmUH3J1ccQAAEUZRhlHGUgZxBnFGWYcFQHGAAATfh9sJB6xwwAAfRkxxgAAlRyWHHwfBYnAAAAy+xn8GfscEdce8gAAAXSCCwsAAAC2AAAB/RQQBMUAAByXxAAAHXFzAPd5FgAAAQDvBMMB8gEDAJYAAAJ1dAABwAAAHRBtAeoLAAABJx0AALYBAALAAMHCAAAcLm9ygvEDAAD6OQsAAMQBXwDICHwBogFgANMB0wBVATKAggLHAAAbQWFwxf3AUgECAAAVexwVMB0Wbh4T4xkXxx4VFrHCAABiGRUBwAAAHc8fYpQd84GGAEZlY29uZHMBbhwAAAF4HwAAqNUAAIYW+p0CAG1zDALdAAAaaWxsaXNlY29uZL0epWVjb25kmRttczIAAgHJAAAgpXB0ZgDlRRkAALAZAADEGQAAXhwAAGYcAABJHwAAOBAB8gABAHMA4bQYAAANHQAAELKAAQPFAAAdQmthYnN2t4DAAoL+QAYaiQBWzj15IWa+hFkKgvAiqQmDzwAAH6LKAAAdlQXAwgJKAAAWMh0awh8X4B4UWh0rAsQAAB0naW0FAsIAABN1Gxs6H3R2G4nMAAAfBysCxwAAG6NldLHHAAByHBOBAtIAACCQZXMrAuAAAAreZHUlAcYAABYMHXfWFNAXBYzCAAARQhISGhUFAsEAABcKGhDkF2l0McUAABkVsRqoHLUBwAAAEgUNbucH8YWGFxghxAAAeRnAGbsE0AAAH85hbnFyBQLDAAAUCx0XBh9pdBsCwgAAHyZudBsCzAAAHzBudRsCzQAAHzFvcqDFb3JrZXIAAAEapIPAAAASdmXaGRZ0bGltaXRRHs0UBALCZXQAABVhY2hlZKoeFHRhdHNQHmNzBIrAAAAYbGV0ZWl0ZW35Hht3bmxvYWRlcnJvcgIfEMhlYXJjYWNoZQAA+B6kAsNjaGUAABR0YXRzkh0WcmFwcGVyvh5zd4AZ8oACAGFs8gNhaWzCFkAAguwRAAABxxkAAPICbWIBAG4B9AcAAABRwAAAahlrGVQdVR1WHRDGcmF0aW9uAAAHHfIAkAAAAcMfAADyBHR0bGUBAWRyAbkNAAAAIcIAAFwdHx4QxmVhY2hlZAAABh0SA29sZAEBxQAAH2lycwHrEQAAAXEfAAASgAGQwQAAEgNoEMVhbmRsZQAAVBnyAAEAaAABcR0AABKAAZDJAAAdlGSygAICwwAAHOhhc2Z08oCKAAAAAv6AdzYLOcSOAWAV9SKQ28EAAAsxZ5SrrKzGAAAZYRVJv4uVyAAAG6I7wYu65Q3dPXeEVGsA4fNCBJctFj0/qjZNxAAAF4oDlAv0HR2nwK5safGyF/9ydQA6MAAAAQAAAAAAHgAQAAAA6gP3A/gD+QP6A5wGnQaeBvkIexB8EH0QfhB/ELQQtRDxFPIUhRaJF4oYkBicGLIarxtcHGkcoh12Hk8flx8lgcYAABL+Hh0dih0FAsEAABRbICSQHWMgbW7zBAFj8kEZAABtBQUCwgAAtvccE2QcZWz7igWOwAAAFqkaEv0ME+oXBQPAAAAUsBsW2x4TBBlicHVlAsAAABHeGBPiF3J0KRYqFisWLBYtFoIcK8EEgMUAABnMxgAAHGEVAcYAABdgIG0iHyOCAsIAABt0YWshwgAAIxX5HgUCwAAAEp4fFSgdbnRrwASEwAAAHmoFAsAAABOWG0CdHJ4cgCCBIHV5BQLBAAATsRsWph1mayuJyAAAHaUrAuAAAB6LbnIFAsEAABJbGSPIHmIfZnShxQAAuRdvAP2BQAzFAAAeQ8oAAB2nzQAAH8wFAsAAABQiHaICGWN0GwLLAAAfz2F5tYTCAAAW7R5yHBOCAswAACCcZHMhwwAA4xfYHiHBAADGEzMe8AJyZQAD4+TlMm1zAekLAADyERIAAMMF8gCQAml0AOodCwAAWAsAAOMUAAB2FgAAqRcAAO4ZAACIGwAAjRsAAIUcAAAmHwAAmyAAAAUAIQEQABDJYXJhbWV0ZXJpAADZGRDGbWFnZTJkAACoGPYAAAPAEkDDAAASH8IAABBZaXB0AILlBwAAoccAANEeBQLAAAATrxoYrh9jZQUCwAAAET4eFsAfZXQbAsMAAB2PZXQThALIAAAgWHN0BYbBAAAR9B6i8xkFAsEAABY9HhRzH2Nw+8AAZAUCwAAAuKYbE/Meb3IhygAArx40HwUCwAAAFoEfFVMeY277AmFoVYLEAAAXZSAzHDQcNRw2HGsgBIXGZXlyaW5nAAAYdmFpbGFibGUvHxRubmVydR1SgAGFwgAAIJNrAAAB/Z1ZSjFoYpHBwQAAFNIdiRCBQGOjWA1JLF9oPyHJAAAfEsEAABeSywAAHtfNAAAfzcIAABmZQlMG1SrCAAATOj6qcBU4dADreRAAAL0UAAA9FgAAfhcAAAkZAACSGQAAqxkAAG8cAAAvHQAAQB8AAJMfAACcIAAAEAExAQAAUcAAAGgUnBsOHTgdOR3zAgFziKcQAAArAsgAAB+ob3khxgAA7RmFHdHDAACiEDHEAABmFGcUmxkVAccAABZhIHI/HxUBwAAAMDwNgRxiIHPrGAUCwAAAI14fnR8TfhtvchDMcmFyeXR0bGhvdXJzAABPHrKAiAPDAAAZoGVpdLYsAAH9FyEHxAAAHFzGAAAcrVCew1MvG605bH1wADowAAABAAAAAAA5ABAAAABuBm8GcAZxBl0NXg1fDWANZA17DVEQUhBTELEQuhDCEAESBBIFEhASOxPRFNoU2xT2FCMVlBemF+UX5xj4GHUZdhnrGbAauBq5GlEcUhxYHGAcaBytHP8cEx1DHUQdRR1GHVMdXR1eHacdPx5bHvkeqB9pIAUCwAAAGw4fGW0eYmwQxGRvd24AALgNMoABAscAAB16cmx1AAD9ARAi/oB/6h5D8T+XeH21Uf8xBngyYXdDO6Xz43FFoY8aJjwxW1MbjBFV6MMAAByUT63t64Uvc0t3osQAAB0ZI+00mMBf4gSpMHUQs/H+XiLDogtb2ZARwCzeJLzvLpC/gnLCAAAeRicFG+pFYOwm8rMAOjAAAAEAAAAAAGQAEAAAAKoGqwbzB9II3AjdCN4I3wjgCOEI4gjjCOQI5QjmCOcIJAklCSYJJwlICkkK8QryClILUwtUC48LkAuRC8ALBg0HDVsNrQ2uDa8NXRCjFPsU/BT9FDQWNRY2FjcWOBZGFkcWnxe8F74Y7BghGSIZIxkkGTAZOxk8GT0ZPhlAGU0ZXhl3GXgZzxnsGfsZ/BlTG1QbcBtxG3IbcxuBG0YcRxzxHPoc+xwRHUIdUB1aHXkdlB02HkEeVh7UHtUeNR9gH4Uflh9YIFkgnSChxwAAyhlVAcIAABBJHXNvEHAQcRByEHUYS8AEBeAAAA0j4QAACksFAsAAACIUHWAgFVEdYWQVA8MAABhxHhZdICNdH18fZHN1BhIlAcAAABRaHm1ECUoKa8AigMcAAB+ctQLCAACm4BkW4hlzd/oMG5HEAAAZ0DHHAADsHnYgeCAlAcQAABObH2SaHoIf84QCY3MrAsUAABqxaG8BgYhmyQoZNwQCwWkAAKt2ZW50ZW1pdHRlcpkcFWx1Z2luBRVlcLIAAcAEgcEAABh+cgABbw0AABDDYXRoAACFFhDDdWZzAACEFhDCaXIAAMwU+hPABAkA4TgJAAAJCwAAAfIhFgAA2QISgAEBEOdZ/opndBDIbmRyZWFzb24AAG0bENJhY2tncm91bmRhdWRpb2Jhc2UAAF8g+p0CAGJlAUDjSxAAAFUUAACWHQAAUyAAABEi8gAAAXOCzAMAAOKKEAAA3BwAADkfAAAbAPYvAP1ABAn9DokGImflH/XAAAAXkG90zbb9APgnDQAA1QQBABoHbQLKAMYATAMAAP5AZJRM8AAAAAAAAEEAAAAAANxcmwEO306PYg2eXx0rfe4d9xgOnFf9YWrEZGCIcoLTOiB+s+rOmR3SNjhmKV7tVB0curNY4OAAAB5awAAAB9kYoNK0QUKRUuOYWRtBc0Y8CV36elMa+NwJL5KK7b10MFaqahxp4GV+VjjbQzJHsGw6zxjQR+dLa1cu1g1VRgiIq1KszvlIX++pMhUAOzAAAAEAACQEuQBbAQAAZAK8ACgDAABOAwAA2QMAAOUDAAB8BAEAlgYBAKcGAQDFBgAA1gcAAO4HAADxBwEAAggAALEIAQDQCAEA1ggFAPoIAAD9CAEAFwkBACgJAgA4CQAAPwkDAD4KBABYCgAAYAoAAN0KAADwCgAA9goGAAkLAQAXCwAAHQsBACMLAAA3CwEAWAstAI4LAADJCwAA6gsAAP4MAAAEDQEAEg0GAC0NAABlDQIAfQ0BALQNAgA3EAEAXhAAAGkQAgCGEAAArxAAAMcQAADjEQAA9REEAAMSAAAOEgEAEhIBAEQS9QDHEwAAzBN7AEoUAABYFAIAZhQBAG8UAwCfFAIAwhQAAN0UAADjFAAA9xQBAP4UAAACFQAABxUAAAwVAAAaFQAAIRYAACkWCQA6FgAAUBYAAGAWAgBsFgAAdhYAAIAWAACBFwAAqRcCALgXAADFFwcA3BcAAOQXAADnFwEA7BcAAJEYAACYGAEApRgAAKsYAACvGAAAsxgAALgYAQDaGAEA3xgAAOsYAAD5GAEAEhkAAC4ZAABEGQAATxkAAFEZAQBnGQAAbBkAAHAZAQCbGQAAoxkAAKgZAgC5GQEAxhkAAOUZAADtGQIABBoDAA8aiQCbGgUApRoDAL0aBABcGwQAfxsAAIgbAACNGwIAkxsAAJ8bAACzGwAALRwAAFscAABdHAAAYRwBAHUcAAB4HAEAghwAAIUcAgCLHAIAlBwAALUcAADTHAAA8BwAAAkdAQAeHQAAJR0AACcdAAAwHQAAbR0AAHYdAgB9HQAAgh0AAIUdAACLHQAANR4AAEIeAABRHgAAUx4AAFgeAABfHgAAkx4FAJoeAACtHgAArx4BALYeAQDaHgAA4R4EAAAfAAAKHwAADB8AABsfAAAmHwAALh8AADIfAAA0HwAANh8AAD8fAABsHwIAcx8BAIIfAAC0HwAAxB8AAMcfAABhIAAAZSAAAI4gAQCbIAAAoCAAAAUDwwAAGb8fInMdvx6nZR9haXJL/REBAcAAABZfyQAAHXvEAAAeZ6HFAABhFKHGAAB4GDHFAAC/GH4cuB4xxAAAhxytHjYfBQLAAACldhgy8RTyFIoYY3XfAf5AAEQDyQAAH2L7kgUCwQAAE24eqSweaXMbksIAABlvpYLGAACxphsIGqHOAABFHwUCwwAAs6IetSkfcHP7AnJzscUAAFgUe/0QQQnCAAAe88AAABhvUcUAACUeJh4nHigejCAVAcIAABatHnKHH/sCaXAFAsAAABN7HBNhH25yy/0BQEjSAAAgUsIAABA3FQHAAAAQCwkw3gcVAcAAABAKCTDdByWEwwAAE5UbEBKnHSWBwQAAEaMZRBZJHhDHb3NlcnZlcgAA2BkhxAAAehybHRDCbmsAACUN8oCEAXQQx3V0YXRpb24AAE4eEMN0ZW0AAOcYEMtoYW5nZWRldmVudAAAbB9yBHVldWUDAcMAAB8oY2ltZAABshsAAKHEAADGHAQCxW9jZXNzAAAxcuUY5hjXGRZlbmRpbmepHm9w8oABAXJsBMDDYXJrCggAABhvbXBsZXRlZE0eFWFpbGVkbRwZcm9jZXNzaW5nqB5VAcMAAKAsHnOZF9Ee0h5FH0YfFAHIdHBlbmRpbmcAABRvdW50Lh9jbBzygIIBchDKdWxsY2F0YWxvZwAABR2gxHJhaW4AAMoUEMNmaWcAAC8SEoABkMEAACCPbgQCwmVhAAAbdXBjb21wbGV0ZWQtHxR1c2VybBtucvKAAgBsbwABY8Ae2v0RBAFL2/r20DrAGaaaKgVt/LI5WMrvjBBMjbCeNnRp4Ad5DHbIbTMX56YjN5taFRfGAAAcgTf8IdYAwQAAHrPEAAAceeHwAwAAwwYAAAH2vhkAAAEA7wB8ArUBowHwgAEBABducwWGwAAAEToTGUMcBcDADEAAABIPHxd5HqnGHDHAAAA6Hjseih4TAQLFAAAesHN2/ygJAAABAAEA0gECAgUFtQUnAYgABQATAvoApQAHAJIBBQLAAAAU6xcV+hhzdBDEdGF0ZQAAvBfyA2FjZQEAcwDilhcAAL4XAABfGQAAABDygAGCZqHEAAD5GbDKcmFuc2NvZGluZwAApR4QzGVtb3RlY29udHJvbAAAYh8QymF0aXZldmlkZW8AAPceEMtlZGlhY29udHJvbAAALB8AT3h0ZXJuYWxkZWxpdmVyeQFeIAAAAbEgAAAEAsVpcmVjdAAAs2xheaIetXRyZWFtKR9wc/KA/RgwBQAQx29tbWFuZHMAAHQdEoFkAYHLAAAgcGPyg29ydAIAZXPygAEBcGQARGNlc3MB6AsAAAHCGQAAAERyYWNr4eMYAADUGQAAEuJuHQAA6R4AAL0fAAASABDHaXRjaGluZwAAcx0Q1nBwb3J0c2V4dGVybmFsZGVsaXZlcnkAALEgAERuZGV4AfYeAAABiSAAAPIEcmVhbYQAAAEYHQAA8gADAHR1d4LpEQAAABDIdWVyeWtleXMAAJEdFAHGcm9maWxlAACgAx1zABoQyGVzYnVybmluAABhHxKCb3IBAcIAAB+kY23wBGl0bGXACNiQAAIDBAn2ASQNAAABkCAAABDFdHJpbmcAAGcQMoACAcEAABfVc3Rk9g2AwAMowABHTIwDaHIVgsIAABOjGb8ZQwADxQAAGaLNAAAfs2V0eYdHCwAABQLAAACoRhymHBlsbwFD4Q0VAAA4GQAAQhWExwAAFIwfVx0jAwLDAAAbVmlyAcMQAAAFAsAAABctHBM9HWFyFf3AGDAAAAAn6hxSHhfrHBJuFBNFFqANJYTAAAATlRsQEqcdLwHABC7gAAAfpsIAABjewwAAH2gF/cAQAQkAABRUGxeyGBLIHBYbGRUBxAAAFrceZFMbFYLDAAARfxijH2sDywAAHrNhaHJBwQAAhguHC1sdth9BwwAAnRyeHIAggSAxxgAAuxq8GoMgAULhyhwAAKUeAABCFQHAAAAXxh9kBB/zgobzgQJjZvsCbnSgxXR1cmVkAAAtEhKAAQHHAAAcQmNuAAJuZwABxgAAG5tmgjYJAADkJg0AAGcQAADuEQAAVhkAAI0eAAAARABxxwAAqSCqIKsgrCCtILYguCDyAnJsAAFmhcUUAADmnRkAAK8cAAByHgAAhB4AAMwfAACVIAAAmiAAAAExAACwwnBlAACBFhKAAYjKAAAgkXkAR2xlY3Rpb27h/hkAAGscAAAQ4YIeAACZIAAAEPIAggAAgyoZAAAABWVzdW1lAAHGAAAgo3ABUxkAAAGcHQAAoMdlc3BvbnNlAAC7HvIGdWFsaXR5AQByAAG8HwAApAHDaW5kAACrcm9tamVsbHlmaW7KH2YrEjHCAAAmHfYeiSChxgAA1B4QwmV5AAC5HvIAAQBr4pgbAACTHAAAAR0AAAAQ1O8eAADEEqMShXVhbGl0AYTBAAAginnygAEBcWIygAECwAAAGXNnZGYSgAEB4AAAGaRuZKDCbmQAAMMU9g8Bbf4ARMEHgcAAABxdAO7wFAAAjhcAAK4XAAC1FwAAihsAAFMcAAB/HAAAGB0AAIAdAACHHQAAwB4AANweAAApHwAAbiAAAHcgAAAAUQMAAAAgABYAgIHAAE7HAAAe3sIAABtVxgAAHEjEAAAe+vYBAJYD4AAADaFhb3kBywMAAAFDCQAABQLBAAAkbhx4HxOnHGNyxQHAAACghhZz/whxwgAAvxfAF8EXwhfDF8QXnB8xxAAAnR5bH3wgAUDkkwsAACMdAACeHQAAEx8AAIsgAAAAMAAQxWxldmVsAACnGBIAAcIAQc0AAB+YzgAAH8XFAAAe+2QAAd8ZAAAEAs1wZGF0ZXBsYXliYWNrAAAWb250ZXh0myAXcm9ncmVzc6IgY3AEAsAAABxnZ2xlZmF2b3JpdGVgHxJwZaYYb3kAQ2F0cwG7FwAAAVEgAAAEAsAAABljdGl2ZXVzZXJfHxZhdGNoZWRMHmF3EMhyY2hpdGVtcwAAoR7ygAIAYXQQw3NlcgAAAB0QwmVyAABLHhDRZXNhdWRpb3ByZWZlcmVuY2UAALUg+oECAGl2EMVlcnNvbgAASh4QxmlicmFyeQAAoB4cAfgAABVlb3BsZZ0fcP8c8oJ2Zf0AiQoA8oADAGFldAwC9gAAFWxheWVkSB4VeW5jZWRJHnBzoMRpbWl0AABRGQCBboQBmQAAGKXAAAAbdmYQxHNlcnMAAP4cEMJ6ZQAAahwQw2VycwAARx4YywAAsCDygAIAaXYQy3VyaXR5c3RhdHVzAABvIPKAAgBjcvKAhgAQzmF5YmFja3Byb2dyZXNzAACHIAQCwAAAHWRpbmdzeW5jY291bnSPIBNzb25yHW5yEMJ0aAAAaRzygAMAYWVsEMhpYnJhcmllcwAA9R4cAswAABVlb3BsZV4fEP0ccHNoHAQCw2l2ZQAAFmVzc2lvbl0gE3Nlcl0fc3UQyGVzc3Rva2VuAACcH/q+AgBjdPKCZXT9AYkKAAQCxWVsZXRlAAAVZXJ2ZXL0HhNzZXJGHnN18gJnZf1IGQ4AhOQLAAAA8oCDAXkQx2Fuc2NvZGUAANMZEoABhMIAABiBcgACZWQAAsAAABzwxQAAHwBhcoM/CwAA5WUUAADwGQAASx8AAFMfAAB9HwAAqCAAAAEEAABJb25pdG9yaW5nAWsbAAABcyAAAHIAwACKAcoAAB8HduHvAwAAugYAAAjzKg0AACkJhAj2A4ACwILCwwAAHF5wcgUCwAAAH2IgGDodZXAFAsAAAKY3HhjDHmNk+wJlbLHEAACLHPIBcwCCgwYLAAA6MAAAAQAAAAAAEAAQAAAAVBQBFaUXehk4HHocuhzYHA8dfh2PHZsdPh92H6wfbyCTIABAAegIAAD6uxcAAP8AQwGVA74ABwB5AD0AQwEBAKDGaGFuZ2VkAAC5FxIAAQLEAAAdGWDsJvKzY2h34fMHAADcCAAAC+ldEAAAoxQAAPsUAACfFwAAvBcAAL4YAAD7GQAA+hwAAFodAAA2HgAAACAAEQBygAOExwAAHUJlc3UhxgAAXx5hIBDHc2Vzc2lvbgAAZxwQxGlja3MAAGYcEMZ0aHJlYWQAAHEd8gJtZQIAcnQAAXUbAADygAIAaXYUAcdvc2l0aW9uAAAfaWNrc2Zyb21zZWNvbmRzpSB00hkQyWllc2Vzc2lvbgAARR4AR2l0b3JpbmcBZRwAAAF/IAAA8oFvAgBudgwC3AAAsmV4fRYSYXSAFmRnEMdlbnRsb29wAABkHKSBwAAAEXTCFD0L8oACAGR2AEdvd25sb2FkAdEZAAABKx0AAAQCwXUAABlpb3Nlc3Npb25EHh5vcGxheWNvdW50ZG93bpsfZHT2DwF0/RmRBMEAYMUAACB3ygAAHzrh8QcAANYIAAAV5WsQAADaGAAA2h4AAOEeAAA/HwAAoCAAAAEEADYBgALABgHDAAASBsUAAB1RcnQAAAP9/L9SK2LDJR5dZPt2YzFKlOV5VGB+bysaILcpaRMcuAFnMQ8vDP7GAAAdUsYAAB/DdbUm6awtKBr5uHQwVqpqzQAAH4TGAAAdU17mJYQnNY4MfiEV0PiTAccAAB1c0AAAIKFhb3IAOjAAAAEAAAAAAD8AEAAAAKgG+gg/CUAJQQlCCTcLOAsSDRMNFA0VDRYNFw0YDX0Nfg03EPcU+BQHFcgXyRfKF8sXzBcSGeUZ7RkEGgUaBhoHGp0anhqfGqAapRqmGqcaqBpdG14bXxtgG48bsxtbHHUchxx9HYUdix1YHl8elR6WHpcemB6tHrYeAB82H2EgockAAPMZ8gAAhAHKAwAAABKAAZDCAAAZP2yhwwAAfBihxQAAZBuxxAAAbBgFAsAAABYLH6CfGGlz+wJmdvuaMcQAAPYY9xinH1WIwgAAF4YeXhRfFGAUQRzJHGHBAACdEJ4QnxCgEFUZrB4hwwAAORYvHPMC/RIBBAEfCwAABQLAAAAavR6lmRttcwQCwWEAABZ1aWxkZXLQGRFkIw1iaRDJYWxzc2Vhc29uAAD2GfKAAgBpdFKAAQLDAAAeqWNuctHDAACYECHBAACvG3YeIcEAAOsXDBoM/cY8AAIAAB1hdGFsb2dpbmRleGVy8R4db3dubG9hZHdvcmtlcvIeFXZlbnRzpBgdYXZvcml0ZXNkcmFpbvMeHXluY3F1ZXVlZHJhaW4oHxQBwAAAEm9ytw1m7gPygAIDbndjdHV2AYCDA1OppmnXbG9yQcAAACcZKBkpGWogscEAAMMaEMZpdHJhdGUAAM8Z8gNyY2UBAmJpcwEFCwAA5uwUAACYGQAArBoAAIkbAACGHQAAPB8AAFcgAAAwAAAABAPAAAAReeMLtHJkZXK/FBplcmllc29yZGVy/Bxib3MSAAEBwQAAHPx0ZADh5RgAANcZAAAQNgCAAsAEDVBYOx0TW+eqrAMjCWpPecQAABnmcnWxwgAAZQ3hwQAAbRYARHNob3SDtA0AAPKTGwAAIgEygAECxwAAHvdwbXTygIGCFYLBAAARaw2vEBUBwgAAFh0fc4oLFQHDAAAWLB9jiRAhxQAAFB/NHxDGcmFwcGVyAACYHABEdGF0cwH9GQAAAdAeAADyB3J0Y2FjaGUCAXN3Y+EqEgAAexYAAAEAdgKAgf0QQQrHAAAZGuAAABnyBQLAAAARfh8RCxpudACAAQGKAAAI1cMAABRdY2IwwnRlAAD7GfwZ+xwaioGIwAAAHteww29kZQAAfRkQxnhwaXJlZAAAlxwIWoKVHAAAAXwfAADyB2VwdGltZXL9FBACAIJ5FgAA9WkbAADDAfIBAwCWAAwC8wAAE2Fjc74UFGFtZXN4Fm1u8oCHiEHEAAB9HEMfRB+nIAhVAeELAAABrxkAABjhAADiCxDNb3J3YXJkc2Vjb25kcwAAYB4QymFja3NlY29uZHMAAJIcAEVjdGlvbgEpEgAAAUAeAADyAXD9I4AEAAABKQ0AAFIAhJLAAAAeXAABkBcAACHBAADJEP8UBQLBAAAehB8fsx9scwkBdi51TqFuUcAAACUNkhe0F2gbGh0lAcAAADZcGV0ZQxxrChWoHmvAmAjAAAAZ48YAABmfUckAAL8cwBzBHMIceyAVgcAAABE+EOQHAER5dGVzAb0UAADhqxkAAG8cAAABEgFlwAVAgaAAAAfwoAAACNTDAAAggoK4BgAA+vUIAACLBwEAwgGMAm8BxwKVAAsAxQIIlYGCkQAACwTEAAAbgQBAOzAAAAEAAHoAAQBPA3oAiMwRAAAAgNAUAP4ADR4LAwSY5MkfpYbb7WPMV1ELMWVuQNvBAAAR9cUAABmMxQAAIHE80ykMzngAAoZhPFLgAJVceljyviTafDAgTyzjLishwQAA7AoIDQBEcmRlcgFQGQAAARIfAAAYzgAAoxjyBGZmbGUCAG1vh9oLAADiYxQAANsZAADdHgAAEQBRwAAAtA21DbYNkxu1HCUCwQAAFgYdFmkfcnPrEXEfwM9leHRlcGlzb2RlcG9wdXAAAGMeEgABwQBCyAAAHyfLAAAfXMkAAB+abgCCOQkAAAQCw2hvdwAAGWluaXBsYXllcicfGmlkZW9wbGF5ZXKaH212DALGAAATb3J0TxkScnmiGHB0EMxyZWNhY2hlcXVldWUAAPAeGOIAAGobEMlhY2hlYWxidW0AAHAd8oJsZMAGaADygAICdXdsdCSCw3JlZAAAG3ZlbnRlbWl0dGVyHx01CQML9gAAmf4ADMQDQMg448jBAAALIs8AACBqzgAAHyLBAAALI8gAABq6APKpBgAAPxYFwMACiAAAF54ZEzAdLV4gsSAVAsQAABO5GiDKHo0gaXNcFBsCxwAAIHFpcwUCwAAAEI8YE4wfZG4ARGx1bWWHthQAAPPbHAAAiwBaAhKAiITKAAAfvhDNcGRhdGVjYWxsYmFjawAAQx4ASmFuc2ZlcnJpbmcBYxwAAAGIIAAAAANuZ3MAwsAAyAAAH9PBAAAeXMUAAB5nhLANAADusBgAAOgYAACDGQAA6BkAAHobAACiHAAAZx0AAIceAADsHgAAhB8AAJgfAAC5HwAAvh8AAHYgAAB4IAAAAIARUQACAADygAIAaXIAS2J0aXRsZXRyYWNrgm4dAAABvR8AABDJbmdxdWFsaXR5AADvHhKEcmVhbYQBxQAAH7xxAEhlZXB0aW1lcgFpGwAAASEfAAAQz293c2VydmVyY2F0YWxvZwAAXB8ARHNpb24BdxYAAAGjGwAAAEZ2ZXJ1cmwBzhkAAAFnIAAAMoACAcQAAB6IcnN08gD9kAgMAAAB2x4AAAwC3gAAFXRzaW5raBsVaXRvcnnNGXJzEMZjaGFibGUAAE4Z8oACAGFwuOcAAK0N8oCDAKQBxHVldWUAABppdGhjb250ZXh0Jh935xEASWxsaW5naGludAFiHAAAAccfAAAEAsZheWJhY2sAAB51YWxpdHlvdmVycmlkZYYgF2Vwb3J0ZXLuHnFy8oACAGxvEMtldHdvcmtzdGF0ZQAA+hyww3RlZAAA5BEQwmRlAACsDRQBy3hjb25jdXJyZW50AAAYb3dubG9hZHN9IGRtHfKAmQAQ1mNrc2NyZWVucG9zaXRpb25vZmZzZXQAAI4gAENtaXQB4xEAAAFRHgAAMoCMgsIAAB7HEM1lbGx5ZmluY2xpZW50AABCHhDSY2x1ZGVjYXRhbG9nYnJvd3NlAABcIBKAAQHgAAAY9W5kEM1jbHVkZWRpdGVtaWRzAADtHrDKZW50ZW1pdHRlcgAA9xwEAsAAABZyZWFzb27MGRdpZnVuc2V0YRxkdvKAxQAEABDJcnJlbnRpdGVtAABgHBDDZXh0AAB2FgQCyGVjdGl2aXR5AAAWb25pdG9ymR8XZXBvcnRlcskfbXL6qQIAbnQygJiByAAAH1IQ0mFja2dyb3VuZGF1ZGlvYmFzZQAAyB8AR2V0dGluZ3MB7B4AAAF4IAAAEoVvcGxheQEBwwAAHppzYwBEcmFja4JmGwAAASAfAAAIQoZnHQAAAbsfAADygmlvAgBzdPKAAgBkdKDHcGhhbmRsZQAAyhkygAIBxwAAH19wdWMAAP7Axe4PATxq851FC8Kkjd4y9RqMpRSsiCcAMQ4uu+Rnr1KWt0irSh8hQW02cSo8iY2ZryTTlptgOi8OjVBBU7Mi+Hw02f7AC9gdRhBBR4m7GikqxCTWxQAAHkx3AU4DAAD70AgAAAEAUgLhAWgJ2AJ1AAEApwJyALsDBQLAAAAU0xek4BljcqUBxgAAFtEfd4gdBQLAAAAjcRwuHhqfH25wNAHBcgAAFnJhcHBlcgEfd/cZ+BlfHKDKYXRpb25ldmVudAAAbR/yhWVyaWZpAgBjZbHCAACFGwBIZ2dsZW11dGUBQR4AAAFYIAAA8oCIAXmgxnBkYXRlZAAA+RkQzWV0cG9sbGluZ2hpbnQAAMcfEMZvbGxub3cAAPYcAEZ2b2x1bWUBZh0AAAHAHwAAAEFrAaEYAAABPh4AAPKAAgBldPYAAP0QgAoBwwAAHY90g6kNAAABahgAAKQCxW9sbGVyAAAWYW5hZ2VyYR4WcmFwcGVyYh5td8gZAIABgZQAABUExAAAHwVmAECCtBQAAIdLGwAA8oACAGRuoMVhbmdlZAAAexkSgAGIxAAAHp9o9g8DaW9u/gBBIAsDyQAAH2RlbW7hPAsAANYLAAADOjAAAAEAAAAAABUAEAAAACcWKBZ3Fq4Y3RmjGkkbohujG2ccwxwhHU4dlR1EHkUenB4dHyQfcB9yH10gMoABAsoAAB17c2V3ocUAAMQcFQLAAAASKQ0SKg1rdP4cUcMAADIQMxA0EP4RSB1hwwAAJw38Ef0RFxmEG04cEMNhbWUAALcX8gJjZQEAbgHVCwAA48gTAAAJFQAA4BkAAPMZAAAgEQUCwQAAFiseGEEfYWMAQnJsha8UAADzzhkAABsDfgO4wgAAcxagwmZvAAAnEiKAAgCuAAAR1GRudgIAA/0EQBMcYOOZwgJS6trY4AAAHkfFAAAdFWludYMyCQAA+usUAACaAwEAUgHRAqIBqQASADcAbQCgyXVkaW9jb2RlYwAAZB0SAIECxgAAHM/CAAAXoGR0AAF1DQAAMoACgsMAABoDZHLygIYAIccAAMoejSAMitEAAKJmc58YpWdyZXNzZBvowwAAbRYAQWSIzBEAAAAATnVkaW9wcmVmZXJlbmNlAboeAADSsCAAAAX2AgFz/QGhAAPMAAAgt8MAABz8Y2VvATEJAADyqxoAAPwBAECCJRIAAAGNFwAAEgRsaXplAYjDAAAbgHI7MAAAAQAAeAACAGUQAQBVEXYAOzAAAAEAAN4BBADcFAAAJBXvALYbdgCoHXYAMoCDAu0AAAzjwQAADShkbnYHAAL9GCAGyQAAHZlpdgD69AMAANICAQABAM0Q1wOUAUYBFwECADHEAABzH3QfZSAQzWVzc2lvbmNvbW1hbmQAAJ8eENJlbW90ZXZvbHVtZWNvbW1hbmQAAFsgEM1lbmVyYWxjb21tYW5kAACeHnYBAMAQYALFAAAePWNw4e0DAAC2BgAAAQASgAECwwAAFFvGAAAgkGRhcwBGYXBob3JlAWQQAADytxoAALAEsMx1YnRpdGxldHJhY2sAAOkeAAJvbgCIxgAAH4WFUBEAAOT+GQAAaxwAAIIeAADWHgAAmSAAABAQALDJdWRpb3RyYWNrAABhHfKBdAMAYWlzEoFlAQHBAAAWTGN2occAAJscIMAAADAJ1AsSgIiEwQAAGaIQzXNjb2RlZGlucGxhY2UAAMYfEoN0cmEBAQaSrD91bnQQxWZmc2V0AABsFqDCbmcAADoLBALBYgAAoWWnDRVvbHV0ZU0ZbHP+Ax39AUEGAsMAABzacnaIrgYAAPlBDQAAEQlAAg8AUgD0AAIAjQLIAUHAAAAxHWgfcSCRIABKcml0eXN0YXR1cwF6GQAAAW8gAAAEwMJldGAQAAAWZWFkZXJzeRkXc29ua2V5c5cbGHVlcnlrZXlzkRwQxm90aWNrcwAAXhwSAAEBwgAAH350bADs0xcAALQbAABuHAAAkhwAAKocAADsHAAAJB4AAGAeAABpHwAAeB8AAKAfAABkIAAApSAAAAAAIgAAAADyAm5kAQBzAOGZGwAAvR4AABDygAMBb3J1cxDDYW5rAABrFgSRwAAA4m1lZRakbWJlcksZyNcAAEwR+g8DAGlucgEvCQAA86EQAABVCbQAEMpwZGF0ZWV2ZW50AACUHBDDZXJtAABkFgQCwAAAE29wZQMVGHJ2ZXJvbmx5YB1jZQAEc3VsdAABEOoQYAtzAbgXAAAAEMJuawAAYxbygIMAEMR1ZXJ5AACeGBDFZW9wbGUAAEoZEMZwdGlvbnMAAOIYEMh2ZW50bmFtZQAAkBwQyGFjaGVvbmx5AAD1HPYfAmNo/gAF8AeEwwAAHqHhLQkAAP8KAAAT8q0ZAABFA/KAAgBycwAA/RU4B/6AYmA8K/xmowxWSpz+LkPvs9SDamVyKGRPB1OKCEXrkbIrMB+upaYopAtkm19ObkqRwgAAHRJN6SRLz8oAAB0JODKfyk1KI7D3O8YAABtHwQAADUBDMvVpxMUAABniXP7JTToAOjAAAAEAAAAAACgAEAAAAOoH6wfsB5MIlAiVCMIIwwjECMUIxgjHCMgIyQjKCPoM+wz8DCENIg3iFJUYvxh+HJscnBxXHV4euB67HrwewR76Hv0eGR9lH2YfyB/PH1wgXyAVAsEAACcUHWAgHY4gbXAiH6HLAADRHvOBgwUCwgAAoHoYE4EfZG8bAsQAAB5TY24FAsIAAFXMHs0ezh7PHp8gELIYYXkFAsAAANEpFqGFHG54K4PDAAAYdSsCywAAH6ZudBUBxgAAEKsecyAdEwICwQAAG29wdeHKHAAApR4AAEI1AcAAABfGH2RoENMZBB/7hhDFYmplY3QAAAIV+hiIAAHTCAAA8wMVAABABGwBdgGAAQTBAAAWOnBkbnV2IckAAK8eNB+gwWEAAP0KEoABAsMAABYzxwAAH4NtY2TygIKB9gCAAv0BAA3AAAAeVssAACC3aG8FA8MAABZ5H6gpHjm6HrAgtSBlb3AViMUAAB+yIMseAUHktwsAAPsYAAB9HAAAQx8AAKcgAACBAQD1AcEAABbDHHOMEDHBAAAGFYIXgxfzggJvdqUBwgAAF0wfaogLAYIC0AAAILXBAAAeS2l2FQHDAAAVnR9w/xwMAsEAABCuFBRyaW5nYxtueRDDaWxlAACdGBSExGFjaGUAABNtcGxfHUkZ8oDAigAAEMd1bWJuYWlsAADHGfKAAgBobxDSYXlsaXN0aXRlbXN0b2NhY2hlAAB8IBKAAYLDAAAeSmwQyWVzdG9jYWNoZQAAWx8ShWlicmFyhAHgAAAeoHkQzGVucmVzdG9jYWNoZQAAnR4QznJlZGVudGlhbHNmaWxlAADoHvYHAMASiQPCAAAdAGlzdQAB2hkAABKAgoHFAAAejAQCw2l0aQAAEmVkSxGpZWZpbGVuYW1l8xxzehDIb21wb25lbnQAAMYZ+goBAGMA9VkQAABZABYAXAGuAvYXgMEIBP1GCAzgAAAfxAAA/kBlf0YOXXy0cO4gN6c0bGyatV+3XCAz+dsN7nG7n3gREhLRT7h4YN9WAjvI229Mu3fzVdcQw6REICSowhABHUbrGX8M0gEKUMbrjhb/uuSFSRsswNJRJdA+NOAAAAffwQAACyTgAAAH4Ho2V9zqO4cychtwzq4cFWQ+x3jSyAAAH9MZMa0KG1kAirKDyAAAGaFOnCCemjEyNDc4YmRmZ2pyc3Z3ADowAAABAAAAAADHARAAAABaAZ0IowikCKUIpginCKgIqQiqCKsI1AjoCPEI8gjzCPQI9gj3CAwJMQk9CT4JUwpUClUKVwpZCloKWwpcCl0K3wrgCuMKBgsHCwgLCwsMC0cLSAtJC0oLSwtMC00LiguSC9IL6AsBDQINAw0KDRkNGg0bDRwNPA1CDUMNYw1sDZgNmQ2dDZ4NqQ2qDasNsA2xDbINsw1LEEwQdhB5EHoQghCDEIQQhRCzEOkR6hHzEfQRFxJIFEkUTBRNFE4UTxRUFFUUVhRoFL0UvhTNFM4UARUdFR4VHxUgFSEVIhUVFhYWFxYYFiIWIxY9FkgWSRZKFlkWWhZ4FoQWhhaHFn4XfxeAF4oXjBeRF5MXpRe7F80XzhfPF9MX1xfYF9kX2hfbF2UYZhhnGGgYaRhqGGwYbRhuGIEYgxibGJ8YoBikGKkYsBi6GOIY6BjtGAgZCRkKGQsZDBkTGRQZFRkWGScZKBkpGSoZKxksGUUZRhlHGUgZeRl6GYMZhBmFGYYZhxmIGYkZihmLGY0ZkhmTGZ8ZqxmwGbEZshmzGbQZtRm2GbcZuBnAGcIZwxnEGcUZ5hnoGfIZ/RkLGqkaqxqtGrUathpSG2EbZBtlG3obexuQG5cbnBugG6EbsBu0GzAcMRwzHDQcNRw2HDccOBw5HDocOxw8HF4cZhxuHG8ccBx6HIAcgRyRHJIcohyjHKccqhyrHKwcsRyzHLQcuhy7HLwcvRy+HL8cwBzBHMIc0BzYHOwc7RzuHP0c/hwDHQQdDh0PHRAdFh0pHS8dMR00HTUdNh03HTgdOR0/HUAdQR1JHU8dUR1SHVgdWR1nHWgdaR1qHWsdbB10HXwdfh2EHY8dkR2SHZYdlx2YHZkdmh2bHaMdpR0gHiQeLB4tHjoeOx48HkceTx5QHlceXR5gHmgeah5tHm8ecB5xHnceeB56HnsefB59Hn4efx6AHoEehx6IHooejx6RHpkeoR6rHsMeyh7MHs0ezh7PHtAe2x7eHuYe7B7tHvUeCB8NHw4fMx84Hz4fQB9CH0kfSh9OH1gfWh9oH2kfbx92H3cfeB9/H4Afgx+EH4Yfhx+OH48fkB+RH5Ifkx+VH5gfoB+kH6wfrR+5H7ofux++H1AgUSBTIFQgVSBiIGQgaiBrIGwgbyBwIHEgdCB2IHggeiB7IH0ghSCHIIogjSCRIJMgliCXIJggnCCfIKEgoiClIK4gryAVAcQAABSwGmQcFUUBwQAAFLwXc5YXvhdfGWAZE4ECwAAAHT5jaQUCxgAAFIgfIxAfhCBjdTHCAADcGN0YxR7zgQJlZxsCwQAAGq1jbhuFwwAAEEgBRecMHQAAFR0AAGIeAAD8HgAAAR8AADcfAABqHwAA0B8AAAAAABIFAsAAABhgHlmeHZ8doB2hHYsgcnSkwsNvY2sAYAAAGGVhZGd1YXJk9RkTYWZlJBIZcml0ZWd1YXJklhsrCfKAAQJsYXIBQeOvFAAAzhkAAOkcAABnIAAAQAAVAcEAABNOHGRsG6DLbGl0ZXNlcnZpY2UAAPMZ8oABgnEBhALBAAAcQlkPmCTSZXMUAcNpbWUAAGRpY2tzRRlGGUcZSBnEGcUZdDkLEMhuZGV4cGFzcwAAwxmgxHJhaW4AAEkR9gAAA/0UIAJHvD2K/x0DHjJZywAAH4NkaXQBTQMAAAA2BAAC/RWAAcMAABqubTi25//gAAAX6cUAAB6LbnMAAXcQAAAVkMAAABYHHcMfEwICUEtTeKJkcgG5DQAAKwLCAAANn290BQLFAAAWMR8XLh1jZHUCwAAAaJYdlx2YHVMgVCBVIBUhFml02AN3BHgEeQR6BHsE9wwhwQAA+RicGgXAwAALAAAhYB2uHxohHhyJH3UCwwAAQAMdBB1vHqseo5YYc3S2GI4ZjxmQGQAafBwgHcHKAABXGzHBAAD9HhkfXCABQjswAAABAAD2AAIAOBAAAEQS9QChwQAAxBygy29jYWNoZWRpdGVtAABdHfIAAQN0aW1zAWMBAAA7MAAAAQAA+QAFAPwIAABFCfUAXxAAAA0SAABMHAAAVQHAAACgLB5zmRfRHtIeRR9GH6HFAAClHEX9wwOAAgAAJTsfUiAzwR7IH18gI4Edwh4VPB/JHhAfdSCEIDHEAAB9G6QciB/7AmN184ECYXBigAECigAACNJ0bnAhwgAAlBvXHKHGAABlGWIAggKhAAAGrGNsAPYyHQAAXgA4AWQANgABAQUEwgAAIgoVqB4x5RjmGNcZFqkeFD8ZaW9wcUKAAYOgAAAD7MUAABw3awCA/QQIGP1q8Ednnp+H2hoQf66lXAlN7VFN0mNVFMYAAB0lN1BP0dZkYEXikQn3kAXeEdiEfKc0GP44hmndRsrUbRkjqDdOAoUiaRER2oPSca++vadkoKFwhQkCzwAAIHXDAAAcl2lwBQLAAAAasyBimA2ZDWwYbRhuGFIbZG+hyQAAGx37hqHCAAD5FAWJwAAAHMEfFGEb+wJjckHCAABIFkkWShaEHRDCbXMAAPIZEoRlbGF5AQHAAAAdmXNtBALAAAAdZWNraW50ZXJ2YWxtczgfs3VudGAWaG8yAAICdNnWBjvMAAAgd2NkYXIA86IYAAC6BMMA8oABhHkWAIABBMMAABj6xQAAGXnGAAAbl8cAAByRcmFoanElAsIAABYGHRZpH3Jz6xFxHxMAAs8AACBqb3EB6BwAAFHFAAC7HLwcvRy+HHogUcQAADkcOhw7HDwcbCAEAsAAAB5yZXNob2xkc2Vjb25kc2kfFWFja2Vy4RhocqSBxXRyZWFtAAARdF0ctRcEAsV1ZXVlZAAAGG93bmxvYWRzWh8VZXN1bHQeHWRyENFvZ3Jlc3NlcHNpbG9uc2VjcwAAcSASgAGIxQAAIKNyGO4AAEQZEMdvd25sb2FkAABiGxDOYWNrb2Zmc3RlcHNlY3MAAGgfEMpkaW9zZXNzaW9uAACcHhKAkAHFAAAgYnT6D/7AAnADAmltAPNTGQAASQTxAQg3AAIQ6hBgC8UAAB6wc3bhKAkAAPwKAAAg+/4MAAAFBbUFJwGIAAUAEwL6AKUABwCSAfKAAgBsbQwCwQAAHmlicmFyeXNldHRpbmdzmB8fdHJlYW1pbmdxdWFsaXR5xR9scxKAAQHDAAAenXJjEoCIAsgAACB3zAAAH8VhcgADbnNlAALCAAAdEsMAAB+MYmmCIQ0AAOriFAAAvxgAAH4cAACbHAAAVx0AAF4eAAC4HgAAux4AAPoeAABlHwAAzx8AAAABAAEBABKAiIHBAAAX6xDPaWRlb2Rvd25sb2FkdXJsAABaIAQCwAAAE29wZUMZGGlwYWN0aW9uQB5jaxDRbmRpbmdkb3dubG9hZHVybHMAAIUgEMJ0aAAAnBjygIMAEM1vY2FsbWVkaWFwYXRoAACXHxDJbmNhY2hlZGlyAACbHiDAAAAgDaYNEM11cnJlbnRlcGlzb2RlAABZH/IDbHZl/gBDIgkAAdMLAAAAEMx1dG9wbGF5Y291bnQAAJoeEgCBAeAAAB7bc4LQCAAA4QQNAAC5GQAAARKAAQHGAAAaA3Ry9gMA/RDADAJYxvsbb2hzAORZCgAATBQAAGUYAACpGgAA5h4AAEMAABDXZW1pc3R5cGVkdmlkZW9kb3dubG9hZHMAAKEgEMhpdGhyZXRyeQAAXB0qDAKCiQAAFK3AAAAfpml3gjcLAAA6MAAAAQAAAAAAEAAQAAAA9xT4FOUZ7RmPG3Uchxx9HYUdix1YHl8erR62HgAfNh9hIPKCdWUCAHN1EM1ubWFya2Zhdm9yaXRlAABZIBDFZWFyY2gAAPIcDInDAAAScnSgIBRwcGVkqCAQx3JvZ3Jlc3MAAK8g8o1lcG9ydHBsYXliYWNrAgBwcxDNcGVubGl2ZXN0cmVhbQAAbiAQyWtmYXZvcml0ZQAAlh+kAcRhZ2VyAACmcmFwcGVyah93jhz6swIAbnIQy3JheWFjdG9yc2F0AADEHwQCxGlkZW8AABpvd25sb2FkdXJspCAYdHJlYW11cmyaIGRzEMlidGl0bGV1cmwAAG0gEM1yZWFtc2VsZWN0aW9uAACZIBDKbWlsYXJpdGVtcwAAeyAEAsRyaWVzAAAddXJyZW50ZXBpc29kZbcgF3Bpc29kZXONIGNl8oD9EAEMAAQCw3VtZQAAFHRlbXNsIBVvdmllc3ogaW0QzWlzY292ZXJhbGJ1bXMAAJ8gENBlbnRseXBsYXllZGF1ZGlvAAC0IPqfwAZAAAQCwAAAFHJzb27nHhpheWJhY2tpbmZveSBlbBDNZXh0dXBlcGlzb2RlcwAAmCAEAsAAABZyYXJpZXOVHxtldHZjaGFubmVsc5cgYnYQyXRlc3RpdGVtcwAAayDygIUAFAHAAAAXeXBlcnNvbowgYpke8gJlbQEAcwE/HgAAABDGYWdldXJsAACUH/KAAgBtdBDFZW5yZXMAAOYeEMhhdm9yaXRlcwAAkx8EAsFkAAAUdGVtc5YgGGlicmFyaWVzriBpbBDIaXNrdXNhZ2UAAKcg+q0CAGRlEMdoYW5uZWxzAABYHxDIdHJlYW11cmwAAJUgENRubHlzdHJlYW11cmxmb3J2aWRlbwAAuCDyhHVkaW8CAG9z8oJldP5AW6oJABDGZXN0cm95AABbHQQCwAAAH2VhcndhdGNoaGlzdG9yeYsgFGVhdGXxHGxy8gD9TFILAOGpFAAACxUAADDkzRkAAOoZAACCGwAAixsAAIMcAAAAERAQwmVzAACbGPKEaXRvcgIAaXnBwgAASh0hwgAAaBsaHRDCYXAAAF8WEMVjY2VzcwAAwhkFAsAAABHaHhE2HnJ0AARwcGVkgQCZAAAc8AABSx8AAPKAiIHyAAOEdHV3AdILAAAAIcYAAB8f1B8QyGhyb3R0bGVkAADDH/IGb2dyZXNzAQB0AAGGHwAACEKFUx8AAAGoIAAAAEJydIXhHgAAAaAgAAD6qIkACGiFjh8AAAGvIAAA8oZheWJhY2sCAHBz8oACiGxyEMZ1dGNvbWUAAMEZEM1ldHdvcmtmYWlsdXJlAAAlHwTAxHRtbDUASgAAGmVkaWFsb2FkZWTCHxdvc2l0aW9u4B4UdGF0ZVodEMNpbmsAALQXEgABA8UAAB+2wgAAHxvFAAAfN3NkaXeEog0AAOWRGwAA1BwAAFUeAADEHgAA7h4AAMkfAAASAAAAQ2ltZQFCGQAAAZ0dAADyAAEAdAABJRkAAPKAAgBkchDJb21wbGV0aW9uAADvHP4PCP2U4AIDyQAAIFbCAAAcSGltdIL6CgAA8t0UAAByBKDEcnJvcgAAYhDygAMAZXJzUMNvZGUAACISIxJcFl0WXhbyAmF0AQBthPYKAADh5xcAADUeAAAQ8oCKAAFBOzAAAAEAAH8ABQBgEAAAyhB7ALoeAACwIAAAtSAAACDEdGlvbgAAYRBIEQSJwnJjAAAcbmRlY29kZWF1ZGlvwR8UZGVjc2EbEgFyggPFAAAbiMIAAB0+xgAAHotjZnWC9AoAAIJ5FAAA8oCGAMDHbGF5bGlzdAAAXRv6KQEAcAABVRsAABKAAQHCAAAcsm1icoACAjmgnwxDYWRjdAUCwAAAxjsZFrIeZW4Fg8IAAByCHxauHMjBAACVHhKDcm9tAQHDAAAfi3BxGgIBhMMAAB7eZgHzCgAAACHGAADIHmIfAER0cm9sAZAdAAABYyAAABKBbwEBwwAAIFtubfIFb2x1bWUBAGMBQRkAAAGuHgAAEMlvZ2dsZW11dGUAAFggEMRhdHVzAACPHQQCwAAAEWs+HhZ2b2x1bWXAH2V08oACAGV08oRzaW9uAgBzdBDIZGNvbW1hbmQAAD0e8oACAG5zEoCCAcMAACCTdBDMbGF5b25zZXNzaW9uAAAkHxDSaXNjb25uZWN0cmVxdWVzdGVkAACmH/4BCcEESAPDAAAfnsgAAB8iY2ls4SQJAADxCgAAMfN5HQAADAIYAfKAAgB0dgBGZWNvbmRzg+wcAAABoB8AABDEb3VudAAAXBvyBG5pbmcCAGNzgkYRAAAAEoCEAcUAAB6xZ/KAiYYARXRyZWFthq4XAADyfxwAAAEBEoJhZAECxAAAGN/GAAAfUXNydRKAiITMAAAfmBDHZGhhbmRsZQAA6xwIdAHqHAAAAVIeAAD6FgIAZGUBoA0AAAASg2lzdAIAngAADaFlchUBwQAAFAsfcSEJENB1ZXVlbG9jYWxzb3VyY2VzAABqIPIDZXNoAQBxAAHoHAAAAEVyZW5jZTswAAABAAB8AAIAYBAAAMoQewDzuh4AAPYBBQAQw2VsbAAANgv2AIAD/QCJAsIAAB0WwgAAHRfhAAAYn2NlclHMAADMHs0ezh7PHp8gwcEAADsZEMhlcnZlcnVybAAA6RwQynVlcnlwYXJhbXMAADweEMlzb252YWx1ZXMAAFkdEMZlYWRlcnMAAMAZBALAAAAQHw0WYnlhdXRoXBxkbfom/gCkQAEAAfAKAAAAUoABAsIAAB7fY3N0lgAAgf2QCQIG/T6JrsQAABzrwwAAGKfCAAAY8wD6NQkAAM4BQQK2BAEAMgABAPIGdwPiAaHCAABCEEWQxQAAoq8cjhcpHyofKx8VAcYAABBvHnN8HPMDAXDiChIAABkWAACiHgAAACD7A3BzdVHPAACvH7AfsR+yH7QgBQPDAACkkx4WIx8U8B5jZXEQxXJzaXZlAADJEBUByQAAEK0fc38dFILDYmxlAAAacnJvcnJlc3VtZY0fWxYSgmVygQHEAAAe3HOxxAAAdh0Qx29zaXRpb24AAFsb8gFkAQFwYwHvCgAAAHKAAgLFAAAfQXJ2bW4Qz2FpbWV4cGlyZWRhc3luYwAA3x7ygAMDbG91YWV0NYLCAAAXlB3xFPIUihgBQfh2GAAAAQDXAFkApgLeATsCGAAjgQLDAAAdLWlsE4GDwAAAHQYAQm9ugu0KAAD2RBAAAAEAhgMBBnwBJQAAAAAByAAAHSRkAe8HAAABJRYAABDDYWZlAADIEBDEdWV1ZQAAqBQQxmVuZGluZwAAmhgQx2FzdHN5bmMAAL8Z9gEAyABZAsMAABn1xAAAGVRnaAABcR0AANYEgMAEQf0EEAygAAAD6zUB4VyTUHzA4vkAAP5AG24D/oKEgDgUpMRaqCH5iGzEJ0k60KYVr0/FDhIAmzbDfYcjSJAHrSOBBgQdClFCLTzG09YFuckwK0QoRWQLB63dGxMZcutWrcYAABUHfazm7yLGAAAfuBo+0jiAVScSaX7FAAAe+zFL9NRfAPzXBwAAMQFPApIAewStAe8C1AILAtgAbgRCAAUCwAAAEogbJZQb1xxlcisCzAAAH7hmdIvABAfCAAAbYcoAAB4jxAAAHzUBggLPAAAgnsgAAB+3YXMbAskAAB/BY2QhwgAAGxX4HjvAAoTDAAAfXJYAAAD9kUBACSNJxAd9E0bqkcgAAB41AQcAAAABVQEAAKWBwAAAEcQftRrzBAFzgk4QAAATAALFAAAU42FjAQEIAAAhwAAAdgTIHAFLOzAAAAEAAO0AAgC2G3YAqB12AAXAwAMBAABTzB7NHs4ezx6fIBaeHhYxGWXAwQBoAAAfQB8V3RQYshtJEUoRyhTLFPMeKB8JgjdgAs9+BQLDAAATHB8Vhx9icyOCAsQAAB40b3IlgsIAABqNHwgSWxYAAAACXdeAwUrKAAAfEmdzgqoGAADoUgsAAMALAACtDQAARhYAAOwYAABeGQAAzxkAAEIdAADUHgAAICEAABASgIICX8KDjgLHAAAeWml0oMxlYXJjaHJlc3VsdHMAADoe8gABAHMA87AQAACzBQgAEoABAsQAACCRI7/oET9rY3MFAsAAAKK0HhpwHmlqEMppdGhyZWZyZXNoAADoHPIAAQB3AAExGQAAUoCCAjido8Jha3QAgMAChP1KmVNmaUxtrhR3UrenNHOubjgwOCzntSQIMdAsOjeUJl4+6q+RyzTEeWq8c/fDBVRCNKtTiTsABLTBUrizKBFnVlflFVszV0XZAAD9FUAY/oBa70taeZp+rS1DLiS4bwdLvSRD+puFRGuG/qAWK4SIuthniPf1+DFItmpJN3WjJFBY0b2s3in78CfPQGzpZ0os+itngA3umYB4HD0uPYIQxldUsVUC87PyMJytnykDa6XtZRCjgutNUsX6Vw9wvF5JYQA7MAAAAQAA6AGNAAYAAABYAPUAWQEAAMsDAAD0AwAAxgYCAM0HCADnBwIABggCAJoIAgCiCAAA+wgAAAcJAAAJCQAAMgkCAEMJAAA7CgAAXwoAAOgKAwD0CgEAGAsBABsLAAC2CwAAzAsAAOIMAAD4DAAAoA0AAKINAwC3DQAASRABAE4QAQBYEAAAYhABAG4QBAB4EAAAkxACACUSAQB3FAMAvxQCAMwUAADWFAAA4RQAAOUUAADpFAIA+RQBABkVAAB5FgEAjRcAAJUXAACXFwEAnRcBAKwXAQDQFwAA1RcAAHAYBQCEGAIAtxgAAOEYAADlGAEA/RgEAB4ZAABLGQEAUBkAAGgZAwBtGQAAjBkAAJYZAQCcGQAAyBkBANAZAADXGQEA3hkAAOIZAADkGQAA9xkBAAEaAgANGgAAsRoAAFYbBABpGwAAbBsAAHwbAACRGwEAlBsAAKQbAACtGwAAXxwAAI4cAQCYHAIAqBwBAMYcAQDUHAMA9xwCAPwcAAAAHQAADB0AABUdAAAbHQIAHx0AACwdAABUHQIAdR0AAIMdAACIHQIAjh0AAC8eAwBGHgAASx4AAFUeAABhHgEAZx4AAGseAACbHgAAsR4BAL4eAADEHgAA2R4AAO4eAADxHgEA9B4AAPseAQABHwEABh8AAA8fAAASHwAAHh8AACEfAAAnHwAANx8AAD0fAABdHwAAXx8AAGMfAABqHwEAjB8AAJkfAQCeHwAAqh8AALcfAADJHwAA0B8CABUBxQAAENsecwQNFYTAAACp8xnKAwWFxAAAEd4UEd8UIcQAAAENsBsBRTswAAABAAB9AAIADhIBAMwTewAigIIBmQAAB+52IcYAAI8bdRwARXRhdHVzAQEVAAABOBwAAAQCxmVjYWNoZQAApG91bnSTHhZuYWJsZWQjH2NlEoABAcwAAB99cmygxW5hZ2VyAACsFxKAgZTTAAAgocUAAB5OEMVyc3luYwAAvhkSgIgBwQAAG0pyAICIg5wAABBfwQAAHyjDAAAdHgBAgh0NAAABRxsAABKAAQHEAAAbtXB3qOEAAJgY4gD9BUABA5kAABDHxwAAH1rCAAAasMIAAB2iZGlwgiIJAAAAAEVudGV4dIOpFwAAgo0bAAAABWFuZ2VkAILDAAAfbIKnFwAAATMdAADygAIAaG8QymxidW10cmFja3MAAFgd/icn/S2QAv4AQIIYwgAAGOfKAAAgaswAAB9CyQAAHyaFywgAAP7nEQAAAQC6AgYAdAAjBHMCpQCCAFsBBQBZAF4AmwAhwwAAkRyRHQAEYXJhbQAB4AAAHjxzASESAAAABALAAACxZcQQtnRpb25hbLsZbnDAw2FueQAApBTyAXkDAW1vcGsB7QcAAPOeGAAAZgAHBjKAAgIhXAexg8oAAB6scnVzdAUDwgAAFK0aFrIcGc4fYmVuoccAALseoMVyZXNldAAAuRkcAf0AABlmb3Jzb3VyY2VXIHPgGBKAAQE45xLnXHB2sgACAsEAAB65b3BrcoPPCwAA+5wXAACKAXIC+wBuAAEA7QHEAAkACQCjABKAAYTBAAAginnygmxpAQF0evKAhwC2AwCQwBkJywAAHLEAOzAAAAEAACoAAgAKACkAXBAAAKHDAAD1DAWJwAAAHncfKGsbcyChwwAAFBIFAsAAACJrGFEfFS0faXD7Am1uccQAABISExIuFi8WMBYxFjIWOwPGAAAU4mxvcqWCwAAAHQ0fFwkQz2Vtb3RlbG9ja3NjcmVlbgAAIh8EAsFhAAAUbHVyZQAVFW9yaXRlQBlpdmIBaAMAiQAACuxlZnIBqQYAAAAQznBkb3dubG9hZHF1ZXVlAAA5HhDNbGljc3lzdGVtaW5mbwAAlRt2AYDAAUIC4wAAHmNwdKDLdGFsZWNhdGFsb2cAADceEoJuZQEB4QAAGHxzZBDEb2NvbAAAnw2hxwAAtB4QwnZlAAD/FFIEcmVzc4T9MAAFyQAAIHHFAAAfNscAAB/D4RkNAACdDQAAMertGAAAnxkAAGQbAACQGwAAgBwAACkdAACGHwAAjh8AAIcgAACiIAAAryAAAAAQAAQAAEHAAAADHQQdbx6rHqDDeXBlAACWGPIDaWxlAQF0cwDkthgAAI4ZAAAAGgAAfBwAACAdAAACAADAynVjdGlvbnllYXIAAFcbMcEAAOUY5hjXGRjgAAA/GQBCbmcBChUAAAGoHgAAcoJzcwICxQAAHqlpcW9wEoCCgcUAABw39gGAwB6AAcYAAB0lYrDEcml0eQAAmg0AR21hZ2V0YWeM3BwAAAE5HwAA8gNhcnmEAOE1CwAAzQsAAAEA8oACAG1vNQHCAAAQ2x5zBA25GboZBQPEAACkkx4WIx8U8B5jZXEARGlvdXOCmA0AAOFsGAAAUhsAACDAyGllcmVkYXRlAAA7GRDFZXN1bHQAAN8YEoNvYWQBkMYAAB9RcjHFAAC6HrAgtSAAAXgAAcMAAB8LcQEhCQAAADKAhALhAAAYn2Vz8oDBCAMCY3PygJ4AocQAAGIQFITHZXNwb25zZQAAFG5uZXKMH1cdEMRubmVyAABWG/IDc29uAgBpcgHDEAAAAPIAAQBqAagGAAAACQEsO/pZl3KgxXBkYXRlAAB3GQADY2tzAAHJAAAgpWaJsBkAAIJJHwAAsMdyb3R0bGVyAABUHfKAAgBoaRDEbGFzdAAAWxwSgAEBwwAAII5yZgBBc4JZFgAAAZEeAAD6Nf0AUAwAmIANAAD61RQAAEcEAQC1AIkBRQFAAj8AtQDPAPq9hIjygAIAaXQFwMQASgAAGsIfF+AeFFodxAAAAP2U8QbIAAAc7xqLWjc3APvJAAAgVswAAB8lxQAAGcF5lNiazWE/oiSowgAAHEgBpwYAAOX6CgAAaRAAAPYRAADdFAAATxkAADAdAAARMAASgAGByQAAHk90wcEAAGMe8gAAkAFiAQAAAKUCwQAAFmEeFmIebXfIGQBCb3cBfw0AAAH2HAAAAEZuZ2hpbnQB/hQAAPJiHAAAZQPygAKCaW4Qwnh0AABeEPICY3mCAAEgCQAAgqUcAADygAIAaWwAAm50ggCXAAALzADiDB8AAHMfAABlIAAAAQD2A4D9AIkDAj1Hj0VmZW4hwwAABRWlGyHAAACBHcIeIcoAAAkfih8hxgAAIx0TH7HHAAB3GzHDAAD2GPcYpx8QxmVxdWVzdAAAhxygxm9udGV4dAAAhRzyBGFja3MCAGNyAAFPHQAA8oABhHIgw2F0ZQAAXRCjFHHFAAB6G3sbohyjHOwediB4IPDBZAAASxvyBHNpb26EAADzIR0AAE8CAgAQzmVjdGlvbm9ucmVtb3RlAACFH/KAAgFsc3TygAIAZXQYxgAAjxsQw3JvbQAAShuoxwAAjRv6I8AKIAABohQAAAHZHAAAAEhuc2Vzc2lvbgFJGwAAASQfAAAQw2FtZQAAVRsQyG92ZWl0ZW1zAADeHjq7AgHEAAAe+m1uc7jCAAA4GRDHb3ZlaXRlbQAAUx0Y3wAAuB4SAAEBxQAAIHxydACENB0AAKjNAABZHPICZW0CAGlzAaYXAADhQx0AAFseAAAwIoACAKQAABZUZHQQx2V0aXRlbXMAAFIdEMRudHJ5AADeGBDFZWxldGUAAFQbFAHFcmVhdGUAABZyZXN1bHS3HmRTGxDHZGRpdGVtcwAAUR3yA2lzdP1dMQEA4RINAAB9DQAAYeQEGgAAnRoAAKUaAABdGwAAlR4AADMzMBjSAADtGfoHAQByAcIQAAABWBwAAEHDAAB1GXYZURxSHBIBZwCMxgAAHoaDMgsAAOReFAAA5BQAABgZAABBHAAAyRwAACAAAPKAAgBudKHDAABGHFHEAACvH7AfsR+yH7Qg8wACYWTnnRAAABkSAABAFgAAmhcAAFUZAABIHgAAkB4AAKweAAAzMQAAFALFb2dnbGUAABN1dGVQHRZodWZmbGXdHm1zNxkQzml0Y2hhdWRpb3RyYWNrAAC/H7DEdmVudAAAixz6J4IAAaUXAAABuhwAALQBwAAAFnJhcHBlch0dd/sU+qaSABKAiQCVAAAWUxDEaXB0bwAArxkEjMAAABtkZW9zZXR0aW5nc74fFGx1bWXbHAQDwAAAGGVlcHRpbWVyIR8bcmVhbXF1YWxpdHm8HxtidGl0bGV0cmFja70fbHR1EMphY2hlY29uZmlnAABSHxDNb3BsYXlzZXR0aW5ncwAAeCAEAsJpbwAAF2V0dGluZ3O7HxRyYWNrIB9zdPKBdQIAZHTygAQAYWNzdhQBwWsAABRpZGVv2hx2UhbygAIAZXTygP0QBBQAENByeXJlc3RhcnRzc3RyZWFtAAB3IATAw29ydABKAAAaZWRpYWxvYWRlZFYgF29zaXRpb24fHxR0YXRlNh4QzG92ZWZyb21xdWV1ZQAAix8Qym92ZXJzdHJlYW0AANwe+p3AAooADALAAAAbb2FkdXBjb21pbmdRHxRpb3VzUhtsdhT+wmF5QEBIAgAAGWxidW10cmFja1AfE3RlbVgcGmV4dGVwaXNvZGWKHxR1ZXVl2RwVcmFja3NPHVEWEMN1c2UAAJUY8oADAGFschDObnBsYXliYWNrZW5kZWQAAIkfEMNleHQAAFAWEMpvdmVpbnF1ZXVlAACSHhDNb2NhbG1lZGlhcGF0aAAATx8QzGlkZW9zZXR0aW5ncwAAuh8EAsAAABN0dXPYHB9lYW1pbmdxdWFsaXRpZXOKIGFyEMhlZXB0aW1lcgAAHh8QxXNzaW9uAABOHfKAAwBlbHQY7AAAVxwQyHFwcmVzZXRzAADbHgQCwWEAABhoZWNvbmZpZ00fGWFiaWxpdGllc04fY3AMAtMAABppb3NldHRpbmdzuR8db3BsYXlzZXR0aW5nc3YgZHTygmV0/kAFQAkAENFpdGJhY2tncm91bmRhdWRpbwAAdSCgymVudGVtaXR0ZXIAABsdoMNyb3IAAPkUENJ0ZXJiYWNrZ3JvdW5kYXVkaW8AAIQg8oDFACQABALCaXMAABtibGVqZWxseWZpbkwfGmlzc3Nlc3Npb24dH2FtEMljbGVyZXBlYXQAADUeBALBbgAAHWlndXJlamVsbHlmaW64HyZyb2xsZXKUG9ccZnQQyWxlZXB0aW1lcgAAtx8Q0HV0b3BsYXljb3VudGRvd24AAJ4g8oRuY2VsAgBhc/KAAwBhb3kQy3JvdW5kYWN0aW9uAACIHwBCbmSC3BgAAAHFHgAA8oNhY2sCAGVnDALAAAATeWlkHB8VYnlpZHOHH2JzEMVxdWV1ZQAANB7yg2RkdAIAb3LyAP1f+AcAhOgKAADpWBAAAHgQAAB3FAAA6RQAAJcXAACEGAAAHhkAAMYcAAAnHwAAmh8AAAAREAEAMoABAskAAB8KcmRwNQHDAAAWZCBzmx+pH54g8gJudAABZIOfFAAA0poeAADoEMRtYW5kAACTGPKBbwIAbXUQ0WJ0aXRsZXN0cmVhbWluZGV4AACJIBj1AAAAH/IEcHBlZAEAcoLwGQAA4lMfAAB9HwAAqCAAAEAAGMEAAF8e8gJydAEAcoLaGAAA4eEeAACgIAAAQPKAiQAQxmFwc2hvdAAAkxvygAMAbnR1BInAAAAScnTaHhRwcGVkSx8Qwm5rAAAaHfKAAgBpdBDHcm9ncmVzcwAAhh/Awm5nAABKHRDGcmFwcGVyAAA3HxDDbml0AAAbHxDGZXN0cm95AAC2H/IBcgMAZGl34ZEbAADUHAAAEgHuHgAA8oVlcG9ydP0QgQIAAE51YWxpdHlvdmVycmlkZdKlHwAAENKGIAAADBjLAAA2H/IGb2dyZXNzAQByAZAbAADjjh8AAIcgAACiIAAAryAAAEAABALGc2l0aW9uAAARc5EepGlja3NJH2108oACAG9ysMhwZXJhdGlvbgAAiBwEAslyYW5zZmVydG8AABRvY2FslCAVZW1vdGWdIGxyHAHnAAAbcmFuc2ZlcnJpbmeIIHTTHCQBxmFuYWdlcgAAFnJhcHBlctIfd44d2R4QzXN0cmFuc2ZlcnJpbmcAAIMgDALCAAAWdXJyZW50tB8bZW1vdGVzdGF0dXOTIGNyoMZoYW5nZWQAAIwd8gJkZf1EEQYA4aMXAAAzGQAAEwAQyHJrcGxheWVkAACQHvKAiQAAQ2luZPKiFwAAkAEBbB4AAAwC/QAAFHVlc3SLHRVwb25zZV4ecXPyA25mbwEAcgGhFwAA4bYcAAB5IAAAMABEbmRlZAHZGAAA89EcAAB3AkEAEMN5cGUAANgeIgVudGV4dAIAiQAAHjNpdILuGQAAAZsgAAAQynBhYmlsaXRpZXMAAF0e8oCJAPon/gBF9QEAAPQ5FgAANQXBABgBGNMAALYe8ghidW10cmFjawEAcgABUB8AAAQCwmxlAAAZZWRpYXR5cGVzjx4ScmyUGG118oACAGJsAAD+wEVSA8AkDh9kGBfyOMYxchFTNY8v8nGrhOCASxoGS153s5tPOV+HquRdMqFaKe0Um1rKeiQ5VQHFftIh6ZgR7YfHB8kAAB8Idq4bXfFmcsVB/oegBgAA5x8LAAA9DQAAChIAANAUAAAZFgAAURYAAB8ZAACiHgAAAgAAAhDSZW1vdGV2b2x1bWVjb250cm9sAABjIPIEZm9ybQEAcgF8DQAAALCAAgIAAsAAAB/GnnR5Y26UAACB/RABSAAanf+WKswAAB2ToMoAAB0lAPJGEAAAGQ0VgcIAABTfHpccYccAAJYdlx2YHVMgVCBVIKjfAADKCxKAhILAAAAZoABDdGVtAXsNAAAB0RQAAGIAAwCgAAAGn2dpbgFhAQAAABDPa2N1cnJlbnRlcGlzb2RlAACOHrYBgALABGLGAAAgdB/+DQYyY24xwgAAZBC3GmcfYcMAADYQPxJAEkESyhnLGUjOAADrB+wHyQjKCPKAAQFzbvKAgYgBQOXxCAAAaBQAAJwbAAAOHQAAOB0AAI8eAAAwABBRxgAAfh5/HoAegR6YIAFBOzAAAAEAAPYAAgA7CQAA7Av1AAEAA8AAABzwxQAAHwDMAAAgoWFydug/CwAA4gsAAGUUAADwGQAAMB8AAEsfAABTHwAAfR8AAKggAAAgARBAAAUCwAAAFtAZESMNYmkbg8QAABkFSwPIAAAZ9scAABmCaW90dQHAAABTIhIjElwWXRZeFm32CvcK+Ar5CucX6Bc1HisC4AAAHRdrdAUCwAAAEf4eU7sQvBCIHIkcihxydKHEAACZG6jMAABOFio5AgCJAAAQwWl0gh4JAADnUBQAAIsXAAB+GwAAch0AACUeAABKHgAA5x4AAIwgAAAwADAABALCc3QAAB5pYnJhcnlzZXR0aW5nc4QfH3RyZWFtaW5ncXVhbGl0ebMfbHPygIyCAEdvdm9sdW1lAdIcAAABqx8AAPIDZW50AQB0AckLAAABCh0AAHIAAgLJAAAevWNzYW0A7eIZAACYHAAADB0AABUdAAAdHQAAih0AAGIeAABnHgAAvh4AAPseAAABHwAANx8AAGofAADQHwAAAAAAAAEAEghLhOQKAADzShkAABQGPwASgAEBwwAAFQJwYqHGAAD3FGHJAABTHFQcVRxWHMAebiAQwmVrAACSGBKAggHGAAAgj3kAB2F2b3JpdGUAAeAAAB9AcwHsGQAAADoLAgLDAAAfLsoAACCFZnNjZADzmhgAANIDPQISgIQBwwAAHwxwlgEAAf1ACQHIAAAbRsEAAAswZACEmAYAABDKa2VuZHJlYXNvbgAASBvyAP0QYAH9DYECADswAAABAAAaAhwA8wMAANMIAAD5DAAATRAAAFYQAQBaEAAApxAHALYQAwBXFAAAcxQCAPMUAAADFQAAThYBAIEWAgCIFvUA4hcBAIsYAQCWGAEAphgAANcYAABDGQAAmRoBAK8aAADGGnoAhRsCACMeAADYHgAA1R96AAX9wTwAAgAAHfEeHfIeFaQYHfMeHSgfJQHBAAAUbxlnwxmDHwUCwAAAIOsXDBojKRJAHmV0AUfkjBcAANcXAAAgHgAAXR4AAE4fAAAEAAAABGxvYWQAAcQAAB6Nc4LHCwAAAdMfAAAQxndubG9hZAAArhkQxG91bnQAAJEY8gACAGNvhRkJAADhmBAAAIYeAABA8gJzZQEAZOHqBwAAwggAAAYBlRgAABDCdWYAADEL8gFoAQBi4eoDAACcBgAAAup7EAAAhRYAAJAYAACcGAAAshoAAK8bAABpHAAAoh0AAHYeAABPHwAAlx8AAEAAAAAAABDDYXRoAACQGBDCcmQAACASBALAAAARcE0WGG50YWluZXJzSR1tb9KDaWFs/RTAAACWAAAQXAwC0wAAFGV2ZWxMFhd1ZXVlZG9wRxtscQQCwAAAwL0QyWRleG51bWJlci8eZG6w0GFja2Ryb3BpbWFnZXRhZ3MAAFMg8oF0AgBiafIAAQBuAAFXCwAAAEAB4woAAPOKFwAAAwKvBBIAAYLDAAAZ2XMAASESAAAQx2xlbHJhY2UAADEZMoACAcEAABIIbG1i8oD9EQAGABDMYXlsb2Fkc3RyaW5nAACNHhjmAADYGPIDb29rhAAAATodAADygWMCAGhwEgCEAcEAAA23ZgAB7gMAAPKA/QAgTcIDQAAA/ZFJCcEPzkP6w02LRQ51AthHNWIZvE0ve7EmD24+T7h8KS5wQhIp9VsGHoHbb9/JAAAckiM0tiQBah0PCezMAAAeYAC0ij10GvwswHA0+nloJEErU+eGT4k1cGVrUT+vtAA7MAAAAQAAIAEYAFwBAABiAQAAXgIAANgDAADbAwEA7wMAAPUDAAB3BAQAugYIAMkG9QAiCwAA9wwAAB0NAQApDQEATRYAAFMWAABfFgAAmRcAAEcbAABkHAAAYx4DANEeAQDXHgAARR8BAEWCwQAAEcwLDB9zH3QfZSArwASE4AAACjzGAAAbnKHCAABiEAFMOzAAAAEAAHYAAQCoHXYA+wJhcisCxAAAH3lwciHEAADSHKsfFYLFAAAULh5FHvMAguIMCQAAuxwAAHogAAADAPMBAXOD4QcAAMHLAABDHSUBwgAAEN4ec7oQUx0rAsQAAB6SbnRBwwAApR+1H4YgkiDQw2lldwAAdg1WAIAB/QEAQ0rEtz6ixAAAHtzgAAAYsnbSAAEDJq0T4SdyZmlwAAHzCgAA8oCCjGHAAACYDZkNbBhtGG4YUhsJAkcMJPEX4AAAHk9jcwUCwwAAGScfGpofbXYFAsEAABNPGRKiGHB0hwGBwAZoyAAAHXDGAAAbassAAB7wFQHAAAC2chx40gigwnV0AAAXCVYAAAH9FBACwgAAGcHAAAAdENIAACCQcAD03QoAAA0BMAkNCJYMgAHABm3AAAAI7+AAAAPkTsOzP8h01PTM8HQFAsUAABK/HqdlH2lygwP9CAkCygAAH6fDAAAcocYAAB5p5RYVAABmGwAAYR0AAHodAAAgHwAAvx8AACEgAAUCwQAAFIkbFIobb3QTggLGAAAdR2RwGwPBAAAcXsgAABtBYWNtscQAAAwNFQHDAACkbxxizxSxygAAdh0xxAAAfhd/Fy8dlwEB/QoBB88AACCCwwAAF4DEAAAYqQHuCAAAsMJlcgAA5we2BQAB/TFBBzRH0HIbwgAADZ/gAAAaCxw2e/J9aADktA0AAPkYAACcGgAAkxsAALUcAAAgAAAFAsAAABlgHhXQH2FyNQPAAAARxB8UVyASvhlhb3k9CbUathoBAQFbsvJC6207MAAAAQAA9gACAPwIAABFCfUAK4rDAAAfjQWJwAAAIrMbWxwbdR8FAsAAABVhHxgEH2J0KwLhAAAQlmRzG4rLAAAeI1HBAAAaGWsbZRxzIH8gEMRpbmFsAAARDZYAgAH9EDAEwAAAGJvBAAAQRlRPXoKVZxjWAAAQDfIBcoQAAOO/FAAAUBkAAPwcAAASHwAAIAASAILAAkhhHjNMi8YAABtbwwAAEGsBYAEAAPLvCgAAMQcAAAL+wAX/XyR/uZcRLNE0dzAxkwCTiMQAABBIWgGV20NrAtfwZzMu6rssZ7ya4hs9DX6Lm8UAAByfwQAAGpzEAAAehsgAABqwNakvL48tq+zIYlI42fJ1xgAAHE58Tff5n1dIgxxOce1POl5kaTswAAABAAD2AAIABgAAAFgA9QA6MAAAAQAAAAAAIwAQAAAAzQfOB88H0AfRB9IH0wfUB9UHmgibCJwI4gy3DU4QTxBiEGMQ4RT5FPoUnReeF7cY5RjmGIwZ1xneGQMafButG4Mdax4CH5kfMcgAALoesCC1IAECAsAAABzwxQAAHwBhcuY/CwAAZRQAAPAZAABLHwAAUx8AAH0fAACoIAAAIBBAACHJAABrG3MgYcgAAJYdlx2YHVMgVCBVIEUBwAAAGFcgZpMXgRjiGOYZscEAALsZ8gJvbgACYXMBFgkAAAHgGAAAEoCEA8MAABYhxgAAGdPOAAAfcGFydgBEdGlvboK7EAAAg4gcAAASgIEBwgAAHQpjqOsAAPcUAElpdmVzdHJlYW2EUxwAAPLAHgAArgEYxQAAMAsQyWVuZXJhdGlvbgAARhvyAP1ACQEAhJgGAAAAMgACiMQAABUCbnIA9NMIAAAwDEAEbAF2BwAC/gBApgwPjMkX43q2+jW+ygAAHwdldADrYgEAANgDAADvAwAAdwQAALoGAAD3DAAAHQ0AACoNAABTFgAARxsAAGQcAADXHgAAAASAEAAAwcMAAFcbocUAAHcZFQHCAAAWsB52bBkVhMQAABSMH1cd+wJwdUvAAErCAAAfhcoAAB/UK4PBAAAWawXAwAgkAAAUjiAWhR8ksxtbHCUBwgAAEJcbc5UZxh4lAcAAABinHHMuHH4fBQLAAACwuxkR4BRschUBwwAAwB0Vc5MdNQHBAAAWAR939xn4GV8cocoAAG0f84ICY2UzgQNeQky3WmlzdBDQZGVvcGxheWJhY2tlbmRlZAAASB9SgISDwwAAHVmlAsQAABZhHhZiHm13yBkAS2F5YmFja2VuZGVkAdEcAAABiR8AAPKAAYhsUcAAACIWIxZZFloWkR6lAcUAABbRH3eIHRDHdnVwZGF0ZQAAMBmygAEDyAAAHiFwYWVzocQAACkecdAAAKkgqiCrIKwgrSC2ILgg8wACc3b3/xEAAJoHXANrACECLQD4AKQBwm5lAACpZXBvc2l0b3J5ixty4QrygIQBeQEBgll9fdU9Aa8YAAAFg8AAABNbEKJtHwFHOzAAAAEAAIoAAgCzGAAADxqJABWEwAAAGSIexhkhwgAAcRwuHvsCZHQBQzswAAABAACJAAEAPBOJAGYOAAD+AEKoKuAAABBsyQAAH5/CAAAbrQFfAQAAOzAAAAEAAI8ABADmAwEAyQcAAAkIiQDEEAIARQLFAAAYfSAWsiBkbqgZqRmqGW0docUAAHsZFQHHAAAQnCBzER0Qw29jawAADw3yAAEAbAABBgkAAHYBgIL9gUAIxAAAHp8AAP4ABSYIDm4fL4eQPH1KSUNMt/uDvHFM+txBU41tmZNPwymTYWYpr49PRgLEiZIeyaESMHQQz3yuTTXuDvkj+k7gUmS1e4GSCQHWt7h/Zj4s9gk2Hs39Pd1vXyFHWbPUrCT1/VZKFI+00Z8wYWRmZ2lrbm9yc3R1eQA6MAAAAQAAAAAApQAQAAAAFgkeCR8JLwk8Ce0K7go8C9YL1wvYC9kL8QzyDPMM/wxLDUwNTQ1ODU8NUA1RDVINUw1UDVUNVg1XDVgNWQ1aDWENgA2BDYINgw2EDYUNhg2HDYgNiQ2KDYsNjA2NDY4Njw2QDZENkg2TDZQNlQ2WDZcNRBBFEGEQoRC7ELwQwxBIEVARURFSEVMRVBEJEgsSKRLLE1AUURRSFFMU0xTUFNUUBhUnFigWdxaCF4MXixeuGOAYGxkcGR0ZYRliGWMZZBmCGcwZ0hndGfYZ/hn/GaMaqhpGG0gbSRtbG20bdxt4G3kbfRt+G6IboxtnHGsciByJHIocoBykHMMczxzvHAcdIR0uHU4dch1/HZMdlR0lHiYeJx4oHkAeRB5FHkoeTh6CHoMenB60HrUe1h7gHuceBx8dHx8fJB86H3Afch+IH9QfXSCMIJkgoyABQOXtCAAAhxAAAK4UAAC/FwAA6hcAAJwfAAAABQAr/RAgQcMAABThygAAHRLDAAAbYxIAAIbCAAAY2AEFAAAAATodAAChwwAAlhgjAgIgIeg8UnN05Z0YAAC2GAAAjhkAAAAaAAB8HAAAIB0AAAAgAAUCwAAAMWwWRBmOIBZoH2V0EMVlYXJjaAAArRmgyWVwb3NpdG9yeQAAgxwEAsAAABlhdmFpbGFibGWMHhJlbfYUc3QQx2V0aXRlbXMAANAcEMxvbmZpcm13aW5kb3cAAP8e8gNpbmX9RAEDAOEvCwAAwwsAAAMAwMpjaWFscmF0aW5nAABCG3IAAgLFAAAZbmlsc3SCXQEAAPIiHQAAAAEygAEDzAAAHwTDAAASDGZpb3QhxQAA7BheGQUCwAAAFL0XMcEeyB9fIGNzFALFZXJ2ZWQAABd1cmF0aW9uzxwTaW1loBdkdHUNtgCAAf0RAwDFAAAdJcIAABUCcwAA/oAIqQ7+QHdWcTDAhO6sGjG5mFQYQLDJyG2DtCTBJV+3O4ArW8qon0nsmuzKED5ladURmX9zIg6FO0o9RVMETAEHrv4Ibgk9TZmwQa10c1LEAAAYq0JrPwWbKa/ap2QktD34yWL94Od5TFYWuWMtn35EtCTVuct94QAAAVZkoKFwhQA7MAAAAQAAsQIuAAQAAABjAgAA4QMAAOMDAAB4BfUAxQcDAPkHAQD4CAAAMAkAAEQLAADUCwAA4QsAACgNAAA8D/UAJxIBADwTiQAEFQAAjxcAAKEXAACIGAEA2BgAAG4ZAABzGQAAlBkAAK8ZAADjGQAAdhsAAJUbAAC2HAMA2hwAAHsdAAApHgEAbh4AAMkeAAD3HgAAEB8AAKEfAACvHwMAwR8AAHUgAAB5IAAAhCAAAKkgBAC0IAAAtiAAALggAABVgsAAAMJXG1QBpBSlFKYUpxQVwcQECAAAF7UbHn0fHUIfkh6hzAAAOh4lAcEAABCXG3OVGcYekwD9ECgCwQAAEDvCAAALRfglDQAAiwOzBQgAJwEiALQDsgEVAcIAABOEG2SVFwUCwAAAGC0fo3cZY2QBQ+NwGAAASxkAAJYZAAAvHgAAQRMARGFsdWWkexQAAACgxmFja2VuZAAA9BTyAWwCAGJ2gugDAAAA9gCAAcQAStQAACCzbEDKbGF5aW5naXRlbQAAdRl2GVEcUhwSAAECwwAAHEvgAAALRXBpbgDyfw0AAHcPBAPAAAAec2xlZXB0aW1lcnN0b3DXHrRvdW5kDA0abXBsZW1lbnRlZEEbZWZpxf3DUgECAAAVexwVMB0Wbh4T4xkXxx4VFhDMYnRpdGxlc3RyZWFtAAAYHVKAkALBAAAZpWl0IcMAALMbWxygxW9sdW1lAABPHAQCwAAAEWwvGRZlcm5hbWVIHXJzBALCZWYAABN1ZnMWHRNlYWsXHWxwEMppbHRlcmVudHJ5AAAaHxDDdXJsAACsGfqo/gAKgAwACICEAYkAABiP4AAAH85k8oACAHN68gJhbIQAghQJAAAA8oABAW1spAHAAAAUYWx1ZVsQduYDEoCCAsAAAAkGxAAAH4VjcvYBgMIA5ALDAAAgjsAAABiCZmehzwAAgCClAcIAABCkGHNtH2uHwgAAEFsFAsAAABStGao3HmV0AUDnZhgAAD8dAAB6HgAAMx8AAFgfAAB3HwAAcCAAAJcgAAAyMAAAIwACwgAAFNppc/IcCwAAygkBQjswAAABAACJAAEAPBOJABvAAMTIAAAejMEAABT2AQABwgAAG4FiOzAAAAEAAI8ABgBCCwAAug2JAGwQAAB8GAEAoBkAAIAbAAAFAsIAABYMH1Z+Hn8egB6BHpggbnDBxAAAYx5CBnBpc29kZQADxwAAIKbFAAAgaWFmcIKNGAAA1AkfAABbJuj6KoKQ4eUDAACWBgAAAeJlDQAAUBYAAK8YAAAgABDKcmFja2xvYWRlZAAAdBnyAAEAdKQpAwAAAAFFOzAAAAEAAIoAAgCzGAAADxqJACsCwgAAGpxydTDDeXBlAADzFIsYjBgUgcZyYXBwZXIAABJyZ/4eih0QxWFuZGxlAAAZHfIEdGF0ZQIAaHcBnxcAAAH6HAAAoMRycm9yAACdF3IDb3JrAwHFAAAfJWVzdGbhKgsAAMELAABB8p8cAAATBPIAAYh3AAEFDQAAEMdsYXliYWNrAABHHQQCwXMAABR1cmNliRsUcmVhbYobb3TyhXRpYXRlAgBkcBKAiILFAAAc0AQCwmRzAAA1ZWF1dGjxFPIUihjacmFuc2NvZGluZ8occnQAAP1QADT+QEPqCQ/4JnS0Ll9QU7NLkZfqwwezOpBECv04WvPAAAAQSg0+dqcNcbssDQQofTo0mQp6jSwXTExKJD3JAAAfn8gAAB2iVrILAL1qzuGAnEh5f0i0ADswAAABAAASAQkAXwEAAOYDAQDJBwAACQiJAGIKegDhCgEALwsAAMMLAwDEEAIAUcQAALQNtQ22DZMbtRwFAsAAABO+GB2kH3R1IccAAF4gsSDzAAJkc+IECwAAEQ0AALsZAAAAIAkBbU/41HxsccAAAIsLjAuNC1sUshwDHyMfEwLABCHYAAAgs8sAAB7IglEKAAAxwwAA5xnpGXYcAERpZGVvgogYAAAB9x4AAPIDaXZlAQF2c4cNCQAAACHAAAB4FqEbwccAAF0bGMsAAFoQAEdhdGF0eXBlOzAAAAEAAHsAAgDXGAAAxhp6ADswAAABAAB6AAEA1R96APKAAgBkdLoSAcAAaMYAAB/PZJd/BgAAOzAAAAEAADwAFAD1DAEAMhACAKIQBAD+EQAAMRINAGkUAAANFQQAFBYAAEUWAABlFgUAbRYIALcXAACAGAAAOBkCALMaAQBVGwAAkBwAAPMcAQBIHQAAVB4AAHYOgAL9RokBwwAAHwUN2XEW9sAAABTgbXQAAJvwAAAAAAAAAQAAAAAA2HpfAlG4MeVgadU25asAkHgOflwSEa49Zimvj09hKwVg4QkHvtHBUp2Dmgwm6sxwdnFx5sGbYdW1ywUJrytlAX8+LDpiD6V5tERsRmv8UgmZVXE4Jm7j/GV0brPBokakoPN/ce+VwHoGUleD4AA6MAAAAQAAAAAA4wAQAAAAAgA0AGEBIwNNA+4DcwR0BHUEewaYBpkGmgabBrMI7QgWCR4JHwkvCTwJTwpQCu0K7go8C0UL1gvXC9gL2QvxDPIM8wz/DEsNTA1NDU4NTw1QDVENUg1TDVQNVQ1WDVcNWA1ZDVoNYQ1tDW4NgA2BDYINgw2EDYUNhg2HDYgNiQ2KDYsNjA2NDY4Njw2QDZENkg2TDZQNlQ2WDZcNuA07EEQQRRBIEGEQhxChELsQvBDDEEgRSRFKEVARURFSEVMRVBEJEgsSKRJCEssTUBRRFFIUUxSuFMoUyxTTFNQU1RTfFAUVBhUnFigWdxaCF4MXixe/F8AXwRfCF8MXxBfqF64YvBjgGAYZBxkbGRwZHRlhGWIZYxlkGYIZzBnSGd0Z9hn+Gf8ZoxqqGkYbSBtJG1sbbRt3G3gbeRt9G34bohujG6UbZxxrHIgciRyKHKAcpBzDHM8c7xwHHSEdLh07HTwdTh1yHX8dgR2THZUdJR4mHiceKB5AHkQeRR5KHk4egh6DHosenB60HrUewh7WHuAe5x7zHgcfHR8fHyIfJB8oHzofTB9hH3Afch+IH5sfnB+iH6kfuB/KH8sf1B9dIIwgmSCeIKMgJQHDAAAXoyBwUxmcHSHEAADtGYUdFQHLAAAQrR9zfx3TgcACUs8AACBjFQHEAACnux5yvB8TgYPBAAAfiwFD4aIQAAAxEgAATaUBwgAAq8ofZisSIcUAADIcph37hmHHAAAGGQcZOx08Hcofyx8lAcMAABShG24NGbEbBQLAAAAilAvrCxcOH2JvQcIAAGYUZxQMFZsZAUQ7MAAAAQAAdwABAO0XdwDzgQJtdQEBAQZqS4NZZufFFAAAnRkAAK8cAAByHgAAhB4AAMwfAACVIAAAmiAAAEATEADByQAAqRukAsAAABR1YXJkHxITYWZlWRBnc+UHAECGvAgAAOFrDQAA5BEAAALyAAIAZHgA81AdAADxABcCEgCCgcMAAB5OASgDAAA7MAAAAQAA9wADADgQAACvEAAARBL1ABDCaWMAAOQH8oACAnN0bnIVAsAAADA8DYEcYiAgyweZCHN56xgFAsAAABPGGRtPHm5yBQPAAACgMB8jXh+dHxN+G2RvcgWDwQAAEwgSEFcLpALFZW5kZXIAABZvbnRleHSIGxdwZGF0ZWZuix5jdXkUMMVsYXllcgAAWBB3FHgUKM8AABgSdhQ44AAAHRIeEksW9gAAwCEokMQAABkwhCQDAAAAtigAAf0ZWQ3LAAAeOcMAABmgIKJLexzOAAAfd8wAAB85zgAAHw12APJeAgAA7xMJgiGG/+GrNQLEAABSGhlrG2UccyB/IBbQH2l3gx1rHpkfEMRuZGVkAAAuHvIFc3Npb26CAAABRR4AAPIAggABDAkAAOG7HAAAeiAAADD6BwEAc4PhBwAAAMDLbGF5bGlzdGl0ZW0AAEMdAAJlbQAB4AAAHt5zAboQAAABUx0AABKAAQHEAAAeknRuMgACASatE+EnaXBmAAHzCgAA8oCGAAUCyQAAFJQgFZ0gbHIVAcIAABuIIHTTHCUBxgAAFtIfd44d2R4FAsIAABa0HxuTIGNyocYAAIwdqNEAAN8KfgcDAf1EEQbMAAAgg2yDfAYAAOhiDQAArA0AACISAABcFgAAoxcAAOYXAACjGAAAMxkAAH0ZAAAAEhADIPKAAgJkdm50FQHAAAAWHR9zigsbAtEAACChc3QFAsAAAFa/HMAcwRzCHHsgF70eYWzBxgAAOxlBwAAAbA2zHLQc7R7zAAFzOzAAAAEAADEABgCWCx0ALg0NACYWAACkGQAARBwBAFkcAQAQy3JvcnVzZXJkYXRhAABOHAEAAcEAAB65a/iYGwAA+wBuAAEA7QHEABIAowAjhQLBAAAgiml5IwACD4aifYBicfJrGAAA5gYFjMAAABSyHiBzGZUbodEAAIAgEM9yb2dyZXNzZnJhY3Rpb24AALUe1gEAAf14AQANSv7tecUAAB8ncAAB3xQAAAwCwgAAFnJvbWV4dC4Zw3lwZbYQZnQE/MAPAAAAENMYENQYENUYENYYBPzA/wMAABDJGBDKGBDLGBDMGBDNGBDOGBDPGBDQGBDRGBDSGAT8wP4DAAAQwBgQwRgQwhgQwxgQxBgQxRgQxhgQxxgQyBjygPwHAAAavgIAoAAAEhcwcyKDcmF0hgCgAAALwPYegMAQJv0cCAbDAAASAkujf8gQBQLAAAAZ3x4QLhlwdAUCxQAAGFofFR4dZHLBxgAAXRsbA8UAACCj0AAAIHFsb3IlAcEAABEQHW3qCycdKwLDAAAZRGZ1BQLAAAAQQRsVrhxkcEMABOAAAB5aywAAH4LFAAAeITBhZXMBCR0AABUBxAAAEK0fc38d8wCBgsMUAAD7AmR0BYrAAAAzCBWsGK0YEi4cocUAAOkYGwIk2Xa902hvJQHAAAAUdh90MBylHSMEwALAxgAAHzXHAAAfEfJtGQAANwIrkcwAAB9owcIAALYQccMAAEUZRhlHGUgZxBnFGWYcBYbBAACh+hEQ2RkhxAAAFB1gIACAiAGJAAAK3swAAB9pcvYEgAH9EUFBxgAAHQrEAAAY4WilgcQAABFdHLUXFQHFAAATfh9sJB4AQ2FnZYm3CwAAgvsYAADyAAECc2V0APJ4FgAAKQUBAAFZ881a9nSCCwsAAAUDwAAAEiUWFc8fFZccYXN4EMxlYXJjaHJlc3VsdHMAAIoeGOsAAC0ZFITEZWRpYQAAE3RlbesZhxgQyHNwbGF5aW5nAADJHPKAAwBpbXb6hwIAZHNWAQAB/RQQBicMcTwdfrMQfxfEAAAdcWcA93kWAAABAO8EwwHyAQMAlgABRzswAAABAAD2AAMA1xgAAMYaegDVH3oACBkAAeAAAB6Pc+FWEAAAcxQAABIAAARyZWFtAQCiAAAZKnMB8BQAAAGHHQAAsMFkAADDGhIEdXJjZQIAogAAGSdpc4TsFAAAAYYdAAC4wwAAhRukAcZhbmFnZXIAABZyYXBwZXLRH3eIHfKEc2lvbgIAbXQ0AcN2ZXIAABZyYXBwZXIVHXfrFIUYhhjygAIAcnPygAMAZW90EMllcG9zaXRvcnkAAOoZIcIAAE8flx8wxGF5ZXIAAOkU6hSEGPKAAYFsDALzAAAYZWxlY3Rpb27WHhJybMIac3UAQ2RlZILnFAAA0sIfAACU8oFvAgBhY6DDaW5kAABUEAhbg1EQAAD06xkAAM0A9QH7AiKAAgChAAANc2R09gcBYf0AjQcBxQAAHyxjiLQIAAD0iRAAAP4HawCvADKAhAPFAAAbYsIAABBaZG90EM1pbmdmdWxjb250ZW50AACCHBKAAQLFAAAgYskAAB6cbnR1AAD9CQAH/oBJfiArVa0as0BLYYXqDGgaVEcG0rTuHVeYVFL6X2bzqqg5f/k8l8UAABkuThFAwK0FAQT/9Heht5GRQwIfLv0XVxIkE1o5F3goCFI31LsWhT7gigA7MAAAAQAAgAAvAPEDAQB/BhYADQsJADkLAAD1DAEA/QwAAFwNAAAyEAIAiBAAAKIQBAD+EQAAMRINAGkUAAC2FAYADRUEABQWAAAkFgAARRYAAGUWBQBtFggAoBcAALcXAACAGAAALRkAADgZAgBBGQEAUxkAAKIZAADBGQAAsxoBAFUbAAB1GwAASBwAAE8cAQCQHAAA0hwAANscAADzHAEAPR0AAEgdAABmHQAAnB0BAFQeAACuHgAAjR8AAKsfAADAHwAAAYIBbU/41HxsAUHjcBgAAEsZAACWGQAALx4AAEETFAHAAAAQCwkw3gcUAcAAABAKCTDdBzqd/BYBAKAAAAffoAAAB+ByAAEDwwAAHFxwZW55AAH0BwAABQPGAAAWAx8lfh2sHxhxH2VzdBUBwAAAEL4Uc24UENJiZXJ1bnNjaGVkdWxlZHBhc3MAAIMfoMRpZHRoAAC0EBDKbmNhdHRlbXB0cwAAgRwQ1HBwb3J0ZWRhdWRpb2NoYW5uZWxzAABwIKDNZWFtaW5nYml0cmF0ZQAA1B4QyWljYml0cmF0ZQAAQh0Q0WxlZHJlc3VtZWF0dGVtcHRzAABiIPKAAgBsdPKAAgBhchDHemVieXRlcwAAqxnygP0AAUwAAEZldHJpZXODSBYAAAGEHQAAGOQAALQewMVlaWdodAAAbxQAR3Bpc29kZXMBgxgAAAEIHwAAAAlvbmN1cnJlbnQAAccAACB9ZIOoGQAAAW0dAACgxml0cmF0ZQAARhYATHVkaW9jaGFubmVsc4M/HQAAAXcfAADyAP2XgBMAAAHeFAAAAQICwgAAHC4/ml9qh2xzASQWAAArhsAAABBJIMhocXVhbGl0eQAAnBcmGVIAAQLgAAAfpGNzdADyWAoAAAUSBALDdGVyAAASYWNuFBNhbWVFFm1uAUfh3BwAADkfAACw8wCE4jULAADNCwAAIR4AAAEA0cIAAMgXNcLEAkAAACUUH80fJP0Z0B4WmBwqEnsWfBb7AmNpAEhlYWNoYWJsZQFNHAAAAX4gAACgxWxheWVkAACaF/KBbgIAcHIARXluY2VkAUQWAAABSR4AABDFb3J0ZWQAACUZAEZjaGFibGUBpxkAAAFmIAAA+qkCAGFwAERheWVk4RkSAABAFgAAM9JIHgAASBKAAQHHAAAeqGxyAEVvcml0ZYQhGQAA4nAbAACWHwAAWSAAADAAAENsZWQBPxYAAAFtHAAA+p0CAGl2BALGd25sb2FkAAAYb21wbGV0ZWRHHxVhaWxlZIkeY2YQxmZlcnJlZAAAIBnygIoAAEhvbXBsZXRlZAGmGQAAAU0eAADygP0sgAsA8oABAmt0eTHDAABkELcaZx8Qym93bmxvYWRyb3cAAEwc8gABAWRoAVwBAAABIgsAAKUCwgAAFFwbRuwc7RzuHKAfY3NGEfoMAIQBewYAAIJPCgAA9h8A/QCBZ8AaBQYyG0toO3RD27QwSRz6PgCC/QoAAAAA/RPBCMLO9zk+gvLXe+i2q7MAIp3przH2/zTiQveFPPtJE5GPd3Hk67b7SJoGrXglHRm7RipfxpiZagio7Z8SpArPvXnLFJVJcQj8vSMBTLRVy3z1858HfA+FB/t/b3JPjyBBXGELxAAAHv8AOzAAAAEAAFkCQwDaAwAAfgT5AG4GAwC/BwQA/AcEAP8IAwBDCwAAXQ0DAGQNAAB7DQEARg71AFEQAgCxEAAAuhAAAMIQAAABEgAABBIBABASAAAhEgAAOxMAANEUAADaFAEA8BQAAPYUAAAjFQAAZBYAAI4XAACUFwAAphcAAK4XBQC1FwEA5RcAAOcYAAD4GAAAdRkBAJEZAADrGQAAsBoAALgaAQBKGwAAihsAAFEcBQBYHAAAYBwAAGgcAAB/HAAArRwAAP8cAAATHQAAGB0AAEMdAwBTHQAAXR0BAHAdAACAHQAAhx0AAKcdAAA/HgAAWx4AAMAeAADcHgAA+R4AACkfAgCoHwAAaSAAAG4gAAB3IAAAocUAAAEaFYHAAAAUrhp3EBUBwgAAEAkNOCELBQPAAAAcJB24dh0TfxthY3MFAsAAABExGcNCG2N0+/1RAAgFAsEAABY6HRGQGG50IcEAAH8N9hwFAsAAABFpFBfpGW10+4kFAsIAABWqHhRQHmNzFQHJAAAXNR9jbRkVAcEAAHSpIKogqyCsIK0gtiC4IHbiDAUCwQAAMFAQFhJtFBLlFGV0+wNpb3KhyAAACBoViMEAAB+yIMseYwADxQAAGOthZXMBxAcAALHBAACTCAUCwAAAGloeECASYWQFwMOAYAAAErMeFXkcJXocmx0xxQAA/R4ZH1wgAEF0Ad0KAAABGhUAAPIAkAAB4wMAAAAFAsAAABINHRF3HGl0BQLAAAAQ8R4jeByCHXJ2U4IDyAAAH6NlZnTyAAABZAGzCAAAAUISAAASgAEBxgAAHQ5udBDDaXplAAA+FhDJZW5kZXJmYWlsAAA+HQzC6RBAAAARbIIYE2l6ZaUZFWluZG93SxwEAsRpcnN0AAATcmF3yBwUcmFtZT0dZGbygmVkwAhkABDEaWxlcwAAsxAQwmlyAAC2CxDVdmFpbGFibGV2YWFwaWRlY29kZXJzAAB0IPYXAP1pQQDAAUjEAAAbpQDjVQsAAAUdAAA3HgAAXB8AABAQAEdldGFkYXRhARQdAAABYCAAABIEcmVlbgEBzAAAII5tcAABIh8AAABCZmUBshAAAAEkEgAA8oACAGFjEgABAscAABn1yAAAG5ZzcneCeQYAAOErCQAADw0AABAFisAAABfWHhVqICHIAABPH5cfBQLAAAAQdBsWdR9ibxDJYXRpdmVzZWVrAADpGRDCZXkAAAsNIMNpbGUAAFAQbRT2BgFswIgE/QgRCsMAAB9jwQAAGsKD2gcAAPIIHQAAjAPygAIAYWsFjMAAABcfHjRoGWkZ8h4VAcQAABZYHnKUGWUBwQAAEIUgcxUfFh8XHxgfWiCkICsCxQAAH1FwcgUCwAAAEfkYEEwcb3chxAAA3xhnGfuKFQLGAAAkVx5QIBb8HnN35BlByAAAfRxDH0QfpyChxAAAkhkBAgJo0fbkUMUAAB2FYXIB+BgAAACAAQGhAAALKMAAABePZ2ZSgAEC5gAAFhpuZHQQzWVkZW50aWFsc2ZpbGUAANMeEoABiMYAAB9HchDFZHBsYXkAAB8ZEoABAsIAABmRwgAAHSpubHMAAAP+gA7EHwtKar/XOOu+VeRe5Mh/3wmCvI8tcfpR0eFs8WDGMcQAAB6JOjSBZ47DAAAeOR8Gc7AiH95JLoXCAAAXkBaxJinOO4Xzprtns4sZSGFjaYV0BgAAOjAAAAEAAAAAABgAEAAAAMcLyAtFDUYNRw1IDUkNSg2qGK4Z0RkIGgkaDhqkGmIbahudG54btRvqHCsdUh51H9MfEoABAeAAAAfWZHR0LID9RQAI/QBgFwATIIkAAAPkAku6kArAAAAcZDIzwAAAGgs0IccAAE8flx+gy25zeW5jcGxheWVyAADGHBDIeW5jZ3JvdXAAAJkXoMxldHN5bmNncm91cHMAACweoNBpc3NvbHZlc3luY2dyb3VwAABFH6DOcmVhdGVzeW5jZ3JvdXAAANEe8gD9TAAKAADzox0AAJUBNwDygAGCcwmCIcKwcFABQuf9CAAAPgoAABISAAAuFgAAcBkAAL0aAACfGwAAQh4AABQUFAA1gsAAABJeECAJpRymHIv9EQBCygAAHZPgAAAI1cgAABuVUckAAHoeex58Hn0elyAhwQAAXiCxIBDFcmZhY2UAAJYXBALAAAAQpBkSZm9zGWRu8gRyZWFthAAA4lMcAADAHgAAbiAAADAA8oACAHR1EMZoYW5uZWwAAOYU+qMCAmNzcnQFA8AAABO9HhDOHxGPGGNkaQXAwQBGAAAY3h4TVRsV+h6xwwAAOBnFAsAAABe4HhZ8IHJ0NB2hwQAAWRzzAgJpc+KmFwAAQx0AAFseAAADACsC5AAAFlRkdBUBxQAAFrceZFMbEMZwdGlvbnMAAIEYVjQAiP1dMQHGAAAdUcQAABtUwwAAGN7GAAAdUsYAAB1TAOYSDQAAfQ0AAAQaAACdGgAApRoAAF0bAACVHgAAYTMzMPKAAYJ0EMR5dGVzAAA9FvICaXQBAGKCsQgAAPaGEAAAXQFuBwEA/wQCABjQAABcHvIEaW5ncwEAawHoGQAA0oQfAAAUEMdldHRpbmdzAACIHvKCZXQCAHN0EMNhbWUAAIAYsMFkAABqFBDIb2xkc2l0ZW0AABMdGMQAAIceENJ4Y2x1c2lvbmNhbmRpZGF0ZXMAAJwg+oUCAGVz8gD9wCECAOEnCwAAtAsAAAHzXRQAAFQE7wWgxWFpbGVkAABJHBICZXMBAcUAAB9bZnQA5BMZAAD1HgAAfx8AAJUfAACuIAAAMBAA8oJhcgIAaXkSAAEBwAAAG5tybAABfhgAAPYlgMEBQv0cIITMAAAfognKTyA/fHToM05hwgAAlBg9HD4cPxxAHG0gFZDAAAAXkR1cEBUBxQAAoAMdcwAa0cIAAHsQWwPFAAAbbmFsciHEAABQGRIfMcIAAFAdQR5YIBWCwQAAEUEbCR0rAsYAAB6PZG5bmsEAABijCQLXAAAgs8IAABkxaXIFAsEAABhhHxOkH2NtK4nFAAAfYBjIAABpFAQCwmlsAAAQFhISZXLlFGV0+h0CAGZuAbAIAAD2CBUAAEQBWwIFAAEAGgYSgIKBygAAIHQBQDswAAABAAB9AAIADhIBAMwTewA1AcIAABChG3PzHPQcVB7yAAACYXQBIwMAAAE7EAAAMMlhY3lwbGF5ZXIAAJcXmBceGQAAwRAE/sCP9gcRIBoSZlHlFmICL60rY8ADNYQwwcMAABUHJRUCfU9IKZgjsxegyTLFXRBFD/TGAAAfTG/Z/ddXYr5bgXRRGwN331juVfHcFCoCpL0Tp+qZGXuAIOUSZtHYlSkS9cLrLAA6MAAAAQAAAAAAVwAQAAAA3QPeA98D4AOsBq0G6QjqCOsI7AjwCFEKUgpeCuQK5QrmCucKiAuJC9oL2wvcC90L3gvfC+ALJA2nDagNuQ02EFAQbRAIEhYSGBI/EkASQRJjFGQUbRR2FFsWdhh3GHgYeRidGLYYuxj2GPcYDRk3GUoZThlUGY4ZjxmQGacZyhnLGdsZ3BkAGpsbsRtNHHscfBzrHBkdIB0rHowe0x7dHugeLx9eH50fpx9mIH4gkCAFAsEAABO+FBR4Fm1uIc8AAKkfniABAwHRAAAgY3IBfA0AAFWCxgAAF2UgMxw0HDUcNhxrIIv9AoECxQAAHSLGAAAdKMYAAB0p8wACZWYB2QMAABWEwAAAVL8cwBzBHMIceyD4DAUCwQAAoTAfH3cfZW0FAsEAABvfHhucHWVzBQLBAAASPhAWjBljZhDDZGF0AACjGfICbmOCAAABvxkAABDGZWt0aW1lAACiGfKAAgBleRDJZXBvcnR0aW1lAABIHAQCwAAAqGF5ZWRkYXRlRhymc2l0aW9uHBlsbwQCwAAAqXNlcnRyb3dpZMQcEmVtsRBudBDHdXJhdGlvbgAAGxkEAsAAABx0YWxvZ3N5bmNrZXmzHhVlY2tlZH8YYWjyAP0MgQMAANKzGwAAqPKAAQF0cwBEdWFnZYNwDQAAAWkeAADyAAEAZwDygR0AAEEBAQECav8rYQLLAAAgdHN2820QAAAfDqMAyMcAAKwI8oCCAWx2foDAAUT+AFGETsAAAB/G4AAADQBcGoVdpwAA/RFRAP6QO6tfcDSWSKxVKuoZNxTaKJoEVzVIJs4ZdAM1lGjTvT61SlzyqXhLZoZYISTLITBRQciHs2BrH03x0MMAAB5PwQAADQs0IBVKjiRptj0NA2igdHh+TFni/TWrN7C9BFF66/seYu1OCBYAbDdmSQNlz4ISPC8bdQA6MAAAAQAAAAAAeAAQAAAAAwA1ADYATwFQAVEBUgHKA9ED0gPTA9QD1QPWA9cD6APpA+sDcgTaB9sH3Af7B6wIrQiuCK8IsAjuCBQJFQk9CksKTAoECxwLNguVCxENQA2fDT4QQhBDEEYQ7BHtEQISrxSwFLEUshSzFMUUxhTHFMgUyRTmFAgVEhVMFoIYlBinGKwYrRgOGQ8ZEBkRGRkZLxmdGawZuxm8Gb0ZxxnOGcIabxuuGz0cPhw/HEAcdxyvHLAc6RwIHSgdMh0+HV8dkB1yHnMedB51HoQehR7HHsgeFR8WHxcfGB8sH2IflB/MH1ogYyBnIG0glCCVIJogpCBBwwAAfRxDH0QfpyAFAsAAAKWaFyhNHH4gcHJbwABkwAAAFRIFA8AAABKiGSAwCdQLMvMUixiMGGlveQmCTbCXLYJBzgAApR+1H4YgkiCxxwAAiBwFAsAAABFsFhZoH2V08wEBc/IiHQAAAAH7AmZwBYvAAAAWaR4ROxAUdBkxwwAAohcyGWweAYUCKCb1xY1+Eibm2mFwAUXjIRkAAHAbAACWHwAAWSAAADMABZLBAAAhPxZtHBIlH/sCaXYbA80AAB91zgAAH3Zhb3IVhMIAAGiWHZcdmB1TIFQgVSD3DAUCxgAAGEcfFYkeY2bL/RBBAcUAABkgyQAAH6c1AsQAABEzHhPYHml07hnvGZsgIcYAAKYZTR47A8EAABeBbW51K/0BQAzJAAAeXcAAABfWywAAHo4FAsQAABYzHxNZHmhvBQLAAAChpw0VTRlsc/sCYnUV/cAjgAQAACUpEkAeGpIcHWAeEuILIeELrxkpDaUBwAAAoPMRc0QOMcAAADoLOwszFgUCwQAAFIsdFV4ecXPzAQFy46EXAADYGAAAthwAAHkgAAAAMBDDYW5rAACwEABLcm9tamVsbHlmaW6COx0AAILKHwAAMgACgsAAAByhZnKCcgYAAOVHEAAAVBAAACsSAACiFwAAMhkAAGweAAABEABygAHAGIDGAAAfOGTygAECbmRwAQACyQAAHRIqBVHdA2Vy5BgLAADhGAAAaBkAAAEaAADyHgAAEBEAMcAAAJcbkRyRHQADaW5nAIXHAAAfL8MAAB11giULAAD2XBkAAAEABgLPABEAYwHyAAEBcnMA9gsNAACKDMcEVwAGAA0AEMdwbGF5aW5nAADkFBKAAQLGAAAbbcIAABThcG5yNgKAAsAFJNEAACBf4AAAGH8NYnDj+mV5FQHCAAAWfR1yHB8QxHM3MjAAACQL8oABAXB5AAAD/kAb/x8S719yfXXjeT0XUQoxxFZ44ePqXSZzNs7IQKt+WQEC+M857j5p7lGBJnZ4Ao4m6JrKEkMpqe+DwgAAC0Vu7gtJkFWNVjrIDMwk+mdRvV9JAQkcRyTPZh0gb44U+K6p3cMAABzayAAAG5ZiZWkAOjAAAAEAAAAAAE4AEAAAAAUA7AN5BnoGrgavBrAGsQayBrMGtAa1BvUHKwksCSoLKwssCy0LLgvBC8ILDw0lDUENsBAAEgwSFhUXFRgVORZSFmMWaxaQF5IXtBeSGKEYtBi1GOMY5BjzGNQZ1RnWGecZ6RlmG2cbaBtuGy8cdhyfHA0dFx0aHTodRx1hHWIdYx1uHW8deh0+HkMe6R7qHuseIB9QH6MfvR+/H7IgIcQAAJwZVhsnAAL+AEiBCMIAABkSwgAAG5cgxIeD0MQAAB1Z8mENAABiAxDGb250ZXh0AADjFAAEY3RvcgABEskX8ylzgk4QAAAA8oJheQIAYWMUAcJhdQAAEmlifhhsbw0UAcNzZXIAABNhdGGEG2SVFxDLbGF5bGlzdGl0ZW0AAFseBALFZWRpYXMAABR1cmNlhh0UcmVhbYcdb3QUhMJlbQAAoWREHJQXAAAAAeAAAB5wc4I7FgAAAaQdAADygAIAZHQQxG5maWcAAHIZAERpZW504nAZAAC9GgAAnxsAABQAAUIeAADygAIAbG/yAmlu/QSRCACCbQ0AAOQGGQAAOx0AAEwfAAC4HwAAyh8AABEAEPKCbHkCAGZ0EoABAcAAABUCbGMQwnp6AADiA3YBgAMCxwAAH9NhZXJucwmCNL/IYccVAcAAABQLH3EhCQFC5P0YAACZHAAA9xwAABsdAAAfHQAAQSEABQLAAAAfQR8ffB9lbAFD5lILAABGFgAA7BgAAF4ZAADPGQAAQh0AANQeAAAhAAAQCQFSP+K7X3IJgnqTi4uuBQPAAAAZvx8icx2/HqdlH2FpchsCzwAAH3tobwUCwgAAHHofKskedSBlZxsCwgAAFj1heQmGINkBynvDAAAZoABAAWgUAADinBsAAA4dAAA4HQAAABDyAnBlAQBziKcQAAAAEoABiMgAAB+oeSHHAAB0H2UgUccAACUeJh4nHigejCAAR2VzcG9uc2UBvxgAAAG4HgAAoMVydW5lZAAAfBgQwnV0AACvEBDDdW50AAA6FqDEY2hlZAAAehgygIkBwAAAHlZ09gMAwAIq/TJADMgAAB89xQAAGebFAAAgfMgAAB55iaMIAAA7MAAAAQAAJAARAIIQAwCAFwAAzRcCADMcAwA5HAMAvxwDANAcAAD9HAAANB0DAFEdAQB3HgEAmR4AAKEeAADeHgAAayABAHsgAACWIAAA2OIAAKIQAEABbA0AAOGzHAAA7R4AABD6KwEAc56WCwAA4iYWAABEHAAAWRwAAAEQAERvdW50gmYUAAABmxkAAPYFAP0EIQYDaNH25FByrDr/4WFwcoRuBgAAOjAAAAEAAAAAADMAEAAAAF0NXg1fDWANZA17DVEQUhBTELEQuhDCEAESBBIFEjsT0RTaFNsU9hQjFZQXphflF+cY+Bh1GXYZ6xmwGrgauRpRHFIcWBxgHGgcrRz/HBMdQx1EHUUdRh1THV0dXh0/Hlse+R6oH2kgFgAAAf1AAQPDAAAblsEAAB5qwgAAG3we3qXV7W0A6AYNAAA0FgAAIRkAAEAZAADsGQAAcBsAAGAfAACWHwAAWSAAABQwAwAAAACC/oB50kN4QchgFF7VweH9NVLh6tTEAAAfesAAAAuVPbWtMrcb8FXByxb4zRuQRPhcyWXKAAAdM3EXRWJOJCJIaA51c9u2GmU7oKQNADswAAABAACXAAwAfAQBAMUGAACxCAEAhhAAAOMRAAClGAAAsxgAAFEZAQAPGokAUR4AAFMeAAAbHwAAQcUAAH0cQx9EH6cgEMlpZGVvbWVkaWEAAKEZEMtzaW5na2V5cmluZwAAQxz1AcAAAB6zH3RHCwFB5JMLAAAjHQAAnh0AABMfAACLIAAAADAAKwLEAAAYgXByFQLAAAAn6hxSHhfrHGRloA0rAsIAABjebnIFwMAARgAAGN4eE1UbFfoeEMluY2F0ZWRlbmQAAEIcAEluc2ZlcnJpbmeCuxoAAAGDIAAAcoCRAuAAAA2hZXkAAAH9XXlCVS1qAp7GAAAdUR/9dr/CxAAAG1RvnZy2bMYAAB1SXxVlyYvNAAAfhMYAAB1TXuYlhCdJy3McXxDEgMaG0AAAIKFyAOn6CAAAPwkAABINAAB9DQAAyBcAAAQaAACdGgAApRoAAF0bAACVHgAAA2FDMzOhzAAARR8QxW9wcGVkAABlFKDFdWZmbGUAAGMUBALAAAATc29uoRAcdmVycmVhY2hhYmxlKx5hcvYAAAMCxQAAHx1laHRvcwABigsAAKDFbm5pbmcAAGEUBALAAACmY2hhYmxldhimcnlhYmxleBhhdPKAkgAQx3JwYXVzZWQAAIYe8gJuZ4gAg14UAADSQRwAAIjIygAAnRAQw2FjawAAORZ6HgMBcToibFNiZWl0AAEfCwAA0MR1c2VkAACYEPKAAgBhbBDDdGVkAABrDRDIbml0b3JpbmcAABoZMoCYhENEhy7xEMZpYnJhcnkAAF0UscEAAHcbEMhlbXBpbm5lZAAAoBkQyHByb2dyZXNzAACfGfKAAohudBDJbWw1YWN0aXZlAAC6GhKAAYLAAAANCHQEAsAAALNkZXKTEKNjZWSWEGxy0MZ2b3JpdGUAADQW8oCJACHAAABLEc4fEMhjbHVkZWRieQAAnhkUhMVpc29kZQAAE3RlbbkaXBQQxWFibGVkAABbFHYBgMQADAPCAAAevcAAABiPY2RpsMVmYXVsdAAAWBQSgIKQwAAAGG8QxWFpbmVyAAB1GBKAAQHLAAAfpnRuEoABAWj6g+fhbnYQxmVja2luZwAAMxbygAIAaG8Q1GFja2dyb3VuZGF1ZGlvYWN0aXZlAABSIKDIbmx5dmlkZW8AACkeEoNkaW+IgsUAAB959AHEdGl2ZQAAFmVzc2lvbsMcc4wQMoACAggLMXFQxgAAHoxjdWJ2AID+wG+mDwJ+LbpgkXYO/tTcT424Ia9BlUxXHklNrKM0UZuFJLsgH6ucCAM6XFu3eBAYzVA2yFKIJywxpeNICuz/KShvaH9U6lz1RPafLuoyxxxatOM19CCo6OhVFQiqt2drbyHBAADhC68ZBZHAAAATJx8TBRUFAsAAACQpEkAeFNMfY3kQyHJlc3BvbnNlAADiFDYFAAH9I4gEyQAAHJLMAAAeYMEAAAviYwA7MAAAAQAA9wADAPUDAADJBvUAKQ0AAGHBAACYDZkNbBhtGG4YUhsBBP0ICQLKAAAfp8MAAByhxgAAHml9xHN6JOUWFQAAZhsAAGEdAAB6HQAAIB8AAL8fAAAhIACxwwAAmg0FAsAAACKBHcIeOLoesCC1IGxyAQMCVUCGlUxfVOxrsXN2AaYgAAChwwAAnRtlAcMAACB8HWgecxMVFBUVFWQdZR1ZHmHGAAA/HUAdQR0zH3cfcCD7AmhvBYXAAAAywR7IH18gFewYIcUAADsfUiAAAAD9N+gPV0H2TiIS8OULRBNicdgQxQAAH3nNAAAfBACu8MpRQcTVaZ0PbiKXRCyeeD7qFp0sKoo7b8mNy18wAlciG3dmPTYBBAAAAOrFBwAAbhkAAHsdAADJHgAAEB8AAKEfAACvHwAAwR8AAHUgAACEIAAAtCAAADAAADAAAAkCIoQL2krUAAAgs3N4JQHGAAAXtR5mnxmAHAUCwQAAEcYfGFseY3lrA80AAB9AZWxyBQLEAAAUhh0Uhx1vdCuHwAAAFN7AAAAU3wUCwAAAFAUfEBENY2wEAsJrZQAAFHJyb3LhFBtlc3BvbnNlYm9keRIdZXISgIiEzQAAH3IQyGVkaWFpdGVtAAC4GvIAAQBtOzAAAAEAAPYAAgDhAwAAeAX1ADswAAABAACAAQMARAsAADwP9QA8E4kAFQHCAAAxox04H28fbSgdEMdhbHN0YXRlAAC+GPIBcgEBbnYAAcwLAABiAAMAgwAAB9k2ZW8BWwEAAPkCCAAA/AzmAn4EqgJnAAEAUwCeAKHHAADEHBDMYWxscGFuaWNob29rAAA6HTIAAQLGAAAbsnRleQABnQgAAKHCAADKCwBAAaIIAAD2nBkAALoBHwLuASkAEgASgAEB4AAAGaByZPKAgoQAQ2luZTswAAABAAB6AAEAYgp6AAClAsEAABRcG0bsHO0c7hygH2NzRhE1AcAAABBwHnM7FjwWpB0FA8AAABYDHyV+HawfGHEfZXN0AU47MAAAAQAAegABANUfegAricMAABoKNYTAAAChRBx7DdEUlBcbAsIAABt8bXKmAQAB/TABQJEAAAuVxAAAH3pnADswAAABAACMAAQApRgAALMYAAAPGokAGx8AAHYAgAHABEzEAAAfJ8IAAA0IdCHBAABLHP8eBYnBAAAYpBtJOx08Hcofyx8QxHVhcmQAAG8Z8gNhc3MBAGcAAcMZAAAARXVtYmVyhXAYAACELx4AAAQCwAAAGHByb2dyZXNzgBwTZHVlbxhuc3YAAXgDA26sZa2zyAAAH6MzbE5z/2lucGVmdISeCAAA5X0WAADuGAAAoRwAACYdAAD2HgAAiSAAACMAAHYAAIL9IEAFwgAAELDFAAAX1ADmcgYAAEcQAABUEAAAKxIAAKIXAAAyGQAAbB4AABARAAAQ0mVtZW50YXV0b3BsYXljb3VudAAAgh8BQ+NwGQAAvRoAAJ8bAABCHgAAFACgyHRlbXR5cGVzAAA4HQBMYXRhbG9nYnJvd3Nl0v0eAAAcAVwgAADygmRlAgBjafKAkIQygAKJxQAAHpvDAAAZcmxyAAD+AEMKC/5AHOUEYgzsGUx6wmVA/Du0lNSzZ455eFE3BEO+3RXUETE2FlV8uEEh9Pr+sTQp/wJLILQoRbQZbDLLiABePLEzGIQ3GvlOe/+Hsg6eE+7/wwAAHpLEAAAU3QcwbUDnADowAAABAAAAAAAZABAAAABhAXsGswhPClAKbQ1uDUkRShFCEsoUyxTfFAUVBhkHGaUbOx08HfMeKB9MH2EfuB/KH8sfJYLBAAAWQRtGEF8dAQABwwAAFj1i5bEIAACGEAAA4xEAAFEZAABRHgAAUx4AABABAFHHAAC/HMAcwRzCHHsg+wJsdBDDYmlkAACUCwEBhBXBRe/54TULAADNCwAAAVHCAAAOGQ8ZEBkRGZQfKPYAAE0QVxQAQOFLEAAAVRQAABHhlh0AAFMgAAAi8gFnAQBzgooQAADh3BwAADkfAACw8oACAGF5EMhlbWFwaG9yZQAAtxoQxnB0aW9ucwAAkxe46AAAaA0QxWV0dXJsAAAZGRDQb3dubG9hZHNlbWFwaG9yZQAAZx/2E4Fl/UhBBgPAAAAYqMUAABnlMnJ18oABAWdytgGAAv0QgQJLh/KV98oAAB2cYWRBwQAA+Qi0ELUQiRcBAAHFAAAerXL6bA0AAIcEAQCdBSIFAQC2AQYAfQCaAAUCwQAAp4IbFH0dcHEhwgAASBC8GBUBwAAAF1MeY5wgIwICyAAAH6JkcwERHQAAyMAAAN0Ddg4AAf1ZAAda/NnXTMEAAAsXbKE3AAAAOzAAAAEAAAgCJQBPA3oAOwkAADwKAABhCgAAlAsAAJYLHQDrC/YA4wwNACMNAAAuDQ0AaA0CAHMNAQBEDgEAQRAAAHMQAgC9EAQATBEDAMwRFgDGEwAAahQCAK0UAAC0FAEAGhYGACYWAAA7FgEAVBYEAI8YAAD0GAEApBkAAMMaAgBLGwYARBwBAFkcAQDEHAEApB0AADMeAAAcHwAAAAD+JAI8A/7AHcKogQAAAyGDAAADIlaQe+Dtb9dm88N+nZdzTFGFyEm0CDFSX0hRQP4Kpy1r2ML5YzKoFcEFXwIgEWOMdm6MWLcuNbUfWL3HMzicAvPiBz9twifNAAAerA75zugeFnhBic8v/8uRn1ApWmXeAPL2AwAA4xUVAcAAABajG3MjCwUCwAAAGsoeFmAdaXb7AnJ0K/0UAQTCAAAVA8EAABJDV2vOuWYFAsAAABGeGB1qIHJ1IcQAAJwXJhnzgYMFg8AAABSKFxRKGSUBxAAAFgwdd9YU0BcbAuAAAAsibnAbg8IAAB5aBYbAAAA0GRWxGqgcVZ4dnx2gHaEdiyAJA8YAABz1Cy/RCq/NAAAfMWFvchQBxGJyaWQAAKllcG9zaXRvcnmCG3JhCgQCwAAAdGllbnQSEhMSLhYvFjAWMRYyFqRuZmlnFBJsb/IBcAEAY4LbAwAAABDLZWxvYWRzdHJlYW0AAH8cEMVheWluZwAAGBkSgAGIxQAAHuBsEMlhdGl2ZXNlZWsAAOcZEMhzcGxheWluZwAAQRz2AAJsNf0AoQH9FRACxAAAGrrFAAAdMsUAAB0JyQAAH8LDAAAdWgHYBwAAADIAAobBAAAWZMQAAB0nbXQA4mAKAABvFAAAqxgAAAMABYzBAAARQhISGhUigAGIoAAAAVpzBQPAAAAgcx2/HhcKGkD+FOQXYhzHH2dpdABEdG9yeQGTCwAA4yMdAACeHQAAEx8AAIsgAAADAFKAAcAEhMIAABi8wgAAHqFzBQLAAAAZJx0lmBy+HmlyBQPAAAASmhkz/RmSHdAeFNUXaXR1BYPBAAAfzh8S8B4hwwAA9RyBH6HBAAD9CgBCaHQBYAoAAOFvFAAAqxgAADASgAECwQAAHV/MAAAfPWdtdCHAAAB5GcAZ8gJlcgABc/IJCQAAVgHzGRUAAJgF9wESgAEBwgAAHXBkbAAAhf4AB9wZHWt5MH9ZUxU/ojrKHDFqJNAgI/LFAAAfI3MZmSUVJLGUUAkDrdj7RAOFFjEcQpC25Yoh2ZuPZMYAAByQEC4azLYA6QMIAAAqEgAAGxUAAHsWAABJGQAAgBkAAJ0eAAD4HgAAWx8AAHwgAAAgAQEAACWCwQAAGx8dNQkDCyMAAtAAACCzZGUBOwoAAAkCwwAAHS03/IEIMGlsQcAAAOsH7AfJCMoIsMdyZXZpb3VzAABsGLDDZXh0AABlDRDEZWRpYQAAiRAQw3RlbQAAZA0EAsAAABJhcCILEmV0IwttcxDEcmFtZQAAiBDQxm9udGVudAAAKRbygP2ksQCCAQICwgAAFNoyUEProGlz8hwLAADKCRDJZmZ0b2F1ZGlvAABuGQgRAgCYAAANY6AAAAuScnMBXgoAAOc2EAAAGBIAAD8SAAB2FAAAVBkAAMoZAADrHAAAGR0AAAAgAQDygAIAbG9SgAECPbabwiJkZ272AIACA8cAAB+/bnNicnUAAP0RCUT+gDv8DWMJWigTVnDXxXp2pM5lKD+rr6A8FxNBO6tyahj378EAAAsxHnhY7EDGAAAZYUDIOOPIyAAAG6Ii9MeBEWbHGSKK0AAAIGlDc/4eRSH2bKtIf78BKDsAfBgYQWbUi4AEdG2qqxNp8bIX/wA6MAAAAQAAAAAALQAQAAAA6gP3A/gD+QP6A5wGnQaeBqkG7wj5CC0JLglECUoK/woACwELAgt7EHwQfRB+EH8QtBC1EPEU8hSFFokXihiQGJwYAhkDGa0ZshqvG1wcaRzoHPIcoh12Hk8flx9BwAAAnRyeHIAggSAFAsAAABi4HxBvG2VsBQLAAABBcA1xDXINaR5BHxJvGfUZlhtncvsCYXIVAcUAABCrHnMgHSsCwwAAG6VsciHBAADdChoVAYQCTi8duV3GAAAdR2RwYwADxQAAHoZydHUB4wMAAMX9xVIBAgAAFXscFTAdFm4eE+MZF8ceFRZLi8AAAAsExAAAG4ExxgAAXBldGUMcBQLAAAAy/hRiHMcfYGAKbxRwFHEUchSrGGl0BQLBAAARsxASsR5ldBUBxQAAEEAfc+wZKwLEAAAbm2x2O53HAAAcn8cAAByCBYzAAAARtgsahSAhxAAAXBsuHzHEAAD9HhkfXCChxQAA1B4VAcAAABAJDTghC/sDYWlyFcDACEoAABdgHzNQHUEeWCAXbhs22xncGd0eNxkQy25zaWduZWRieXRlAACBGwSFwAAAF2dmaWx0ZXKxHhduZmlsdGVysh4iBmV4dHVyZQIAiQAAGL0ybQEREgAAABQBw2diYQAAEAkNOCELEMVpbmVhcgAAShAUAcpyYW1lYnVmZmVyAAAXb21wbGV0ZTUfY20ZEM9vbG9yYXR0YWNobWVudDAAAFoe8gD9JAgNguEDAAAANQAAAAHylQsAAO0MUcEAAA4ZDxkQGREZlB8FAswAABabIBeiIGNw+wJwcgUCwwAAFUgeFUkecHOhxAAAURkFAsAAABF2GxGlGGZpKwLiAAANaGRuAYEBBmdIqmF0McIAAE0QVxSmGAkCxgAAHtvQAAAgnHF4AEdyZWFtdXJsgoQeAAABmiAAABKAAYLFAAAfunQASm93bmxvYWR1cmyEFR8AAAGkIAAA8oRpZGVvAgBkcxDDYXRhAAAXGRqSAQHgAAAc/mRzEMZjb21pbmcAAGsYMoACAcAAABkZcHNyENNhbGRvd25sb2Fkc2l6ZWFzeW5jAACCIBDCZW4AAIcQ8oFvAgBrdKHIAAAsHgBJYnRpdGxldXJshD0cAAABbSAAABDCcmwAAJ0ZAEhlbGVjdGlvboKCHgAAAZkgAAAyg2VhbQKEygAAIIpzdQBBcwFUFAAA8tgcAABmAhKBdJAB4AAAHlBz8oACAGFyBALIYXJ0Y2FjaGUAABVvbmZpZxQfFHRhdHPQHmNzAElpbGFyaXRlbXOEvxwAAAF7IAAAEoABAcAAABxqbXoF/MIAVAAAHrAgHbcgF40gGwLCAAAeR2l2AARzaW9uAQCgAAAYanOCJxYAAPKiGwAArAFSgAHAAqDKAAAgb8QAAB6Hc/YFgP0QEQwCxwAAHx5seQBFb3ZpZXOEuxwAAAF6IAAAAER0ZW1zhDkcAAABbCAAAPKDdW1lAgBpbQBNaXNjb3ZlcmFsYnVtc4TMHgAAAZ8gAAAAUGVudGx5cGxheWVkYXVkaW+Erx8AAAG0IAAAep3ABkAByAAAIJNtEMV0YXR1cwAAOBzyBHVldWUBAHMAAVccAAAhwgAAhRZpHBDJb2NhZGRyZXNzAAA3HMDIaXN0aXRlbXMAADQdEMdyc3RhdHVzAAC6HABDbmZvhLYcAAABeSAAABKDYWNrhAHGAAAgh3DygmF5AwBiZWwVAsQAABQuHxiPIGNzbBwAQ3NvboRQFAAA8nIdAAB1AfKAAQFybvKAA4FlbHIATWV4dHVwZXBpc29kZXOEfh4AAAGYIAAAEMNvZGUAAGINAEtldHZjaGFubmVsc4R6HgAAAZcgAAAQwml0AACGEABGcmFyaWVzhBMZAADS9R4AAKDygMEBAgAASXRlc3RpdGVtc4QzHAAAAWsgAAAygIWQwQAAFoQUAsJvbgAAE2FzdBIZFG5uZXKcGWZpYQ2gyGF5YWN0b3JzAAC1GvKAAgBycxDJbmZpbHRlcmVkAAB5HhDGcHRpb25zAADmGQBHeXBlcnNvboQlHgAAAYwgAAByAAOCxgAAH3Rib3WEghAAAPTQHAAALQBVAEcBEMRvdW50AACbGVICZW0DAYkAABYmxAAAH15jaXNwhF0NAADyaBwAANcBCGOEDhkAAAGUHwAAEMZlcXVlc3QAAOUZ8oNhZ2UCAHJ1MoACAuAAABj0wQAAHm5tdGRuAEVlbnJlc4RMFAAAAeYeAAAEAsJvbQAAE2lsZQ0ZFmV5cmluZzIcZmsQyWVydmVyb25seQAArh8QyGFjaGVvbmx5AACBH/IHdm9yaXRlcwIAY3OECRkAAAGTHwAA8oACAGFyFALAAAAWbGxvd2Vkyx4ab3JhZ2VzdGF0c1EgYXQIGRDLYW5hZ2Vyc3RhdHMAAFAgAEhpYnJhcmllc4J/HwAAAa4gAAAIZIJ3HgAAAZYgAADygWQCAGlsAEhpc2t1c2FnZYJDHwAAAacgAADyhndubG9hZP0YEAIAoMh2aWNlbmFtZQAAsxoygIqEwAAAFMwQzWVkZW50aWFsc3BhdGgAAHYeEMRuZmlnAABLFABGYW5uZWxzhGYYAAABWB8AABDDaXplAACaGRDDYXRoAACyGvIAAQBwAAGqHgAAMoJoZQIBxAAAH01kc2MSgAEByAAAH05jcPYAgP2BQAGQxAAAH7QBAAERUjxeeHPiihAAANwcAAA5HwAAGwAQxmhlYWRlcgAAsRoSgAGIywAAIHZoAEdyZWFtdXJshHIeAAABlSAAABKAAYLFAAAfuXQAVG5seXN0cmVhbXVybGZvcnZpZGVvhakgAAABuCAAAPKCaW8CAG9z8oACAGR0BP3DYnVtAYAFAAAdZmZpbml0eXN0YXR1c6wfGmxheWhpc3RvcnkTHx5lY29tbWVuZGF0aW9uc60fFXJhY2tzMRwQyG93bmxvYWRzAABxHhKDaXZlAQLFAAAgXcIAAB9dZHN1EoABAccAAB+cdGPygAMBY2x1ZwAA/kDb7g8DLDG1Hqo59SGRd1/c2UgDEgrM675tud88j1iOq2FAX96feVcj0aOBBXz5UOcpanFLh14EVLgrxU/pAxEndNKnX/EmPHHlXUTJTr1dYhA2jatMhwZQsBDdYJWUywAAH2B/fE+zm2VveYMeAwAA9jgJAADRAQEADQAKC9kCEMdleHR1cmVzAABlGAAAAAHFAAAenXSFWQoAAOFMFAAA5h4AADD6MwEAcwHXBwAAABDLcmFtZWJ1ZmZlcnMAADAcEMJjcwAACg0QzHNodWZmbGVvcmRlcgAAEh8SgIKEwQAAG0YSgAEBxgAAHp50bPKBcoUA8oD9MAAFAAAAAv4CVpYFLHk8L181MD69tMAAABioHeRESdvDAAAfMksO55HVdcFOqWRW+yzSj2Q/nmJYxQAAF5MZSKLi3wHw8VARYqnwdJpudADotwsAAOQLAABwDQAA+xgAAH0cAABpHgAAQx8AAHsfAACnIAAAgyEAEAAVAccAABx0IHYvHzHHAACXF5gXHhkQzGxlc3NwbGF5YmFjawAALxwFhcAAABHeFBHfFCKBbgEBoAAACJ1zbXYBgALBAoDgAAAWgGlwAAAD/oB7+UURPYzh/0wOm0JHPncR9f5fq1esQCvG/IxuNKFb2oogqWSEGERqugxmPDegul0iMClxxh9y4SEyB5k/Ig8Y8QsyqQITWH8VIqgdt7wtk7k8nAqUE/inVQoXA8sfNXBpi2FlbAA6MAAAAQAAAAAAiQAQAAAAzAPNA58Gygc2CTcJQwpECkUKRgpHCiULJgsoCykLMAsyCzMLNAs6CzsLRgtVC1YLygvLCwANEA0mDTkQOhBnEIoQixBGEUcR7hHvEfAR8RHyEQcSFBIVEi8SSxReFF8UYBRhFGIU1xTYFNkU4BTkFAoVMxZrGJoYGBkaGVYZVxlYGVkZWhlbGVwZXRlyGbsavBpCG0MbRBtFG2MbaxupG6obqxusGy4cMhxBHEMcYxxlHGwcyRzKHMsczBzNHM4c3BzdHN4c3xzgHOEc4hzjHOQc5RzmHOccBR1KHUsdTB1NHXMdph03HjgejR6lHqYepx6oHqkevx7+HhQfOR9NH1EfUh9cH34fzR9zIH8ggyCIILMgBQLAAAAUjBkm3RmVHWVzBQPAAAAVbhkTDBIbCx9vcnNFAsAAADFsFkQZjiAWaB9ldIQWnxigGBYdBQLAAAAWghwXBR1jbBDDdXJlAAAICRQBxWVzdWx0AAAWYXJpYW50sB52bBkQwnJnAADgFPKEdGlvbgIAYXISgAEBwQAAHGFjc/KAAgFudGwBAgHPAAAganEB6BwAAAUCwAAAE6UgEDEdb3MTgQLCAAAfPWNywccAAJUeIcYAADIcph0hwwAADRmxGwBHZWxseWZpboIGGQAA4TsdAADKHwAAEQBGb21tYW5kOzAAAAEAAHcAAQDtF3cAALYLAW0C/gAMYQHBAAAZLsMAAB+LY2o7MAAAAQAA+gACANoDAAB+BPkAOzAAAAEAAPcAAwBDCwAARg71AEobAAChxAAAtB4QxGVhZHkAACUWEMhleHR1cmUyZAAAER8SBXVmZmVyAQLGAAAfNQeQx/9ZdGNzAPJtGQAANwL6EAIAYnIA8ogQAAC1DPKAAQFtY/KAiYKxwgAADA0FAsAAABRXIBK+GW95BQLAAAAUix0VXh5xcxDJdWV1ZWRpdGVtAACwGhQBxmVjb25kcwAAE29uZ34fbCQeEMNvbmcAAC4c8gJtZQIAbHMBJBYAAAAigIYAlwAAEEkSAXQBAeAAAB+kdHMBWAoAAAASAIEB0QAAIGNyAAF8DQAAEMxsbGVjdGlvbnR5cGUAACMeEoCIgmcLIfjMEMVpZGRlbgAASBD2EQDAAxL+AEQKGcIAACBpywAAH3XFAAAcn3xN9/mfyQAAHmAA0uIMAADVAENkZXIBBwkAAIOTEAAA8gACkGxyAOwoDQAAJxIAAAQVAACPFwAAoRcAANgYAABzGQAA4xkAAHYbAACVGwAAthwAAG4eAAB5IAAAAQAAAAAwABDDbmNlAAAGCfIAiADSAgAAADIBix4AAAhEOzAAAAEAALkAAQBkArkAAKHJAACDHAUCwAAAGYweEvYUc3THAAL9RAEDywAAHv/GAAAc0MQAABmt4S8LAADDCwAAAwEAAsIAABijURsDd99tb+PaCwAAYxQAANsZAADdHgAAYRAQwmF0AADWBwCAAQGgAAAD2eAAAA0AdGf2AICJlsEAAB0WAQCQyQAAH7jrQwoAAEYLAAAUEgAALxIAAEsUAADXFAAAWxkAAHIZAAAUHwAATR8AAFIfAADNHwAAQBACAAAAwcgAAEIbJYbEAACkbR8Zbx8HHzof+4UQx2lsdXJlYXQAAC0cEoCBAcIAAB09chqCAQLDAAAfo8IAABzIZmNkEoABAcUAAB7/c20VAcMAABOEG2SVFwUCxQAAFIYdFIcdb3QFAsAAABmiHRWcGnF0AUTjcBkAAL0aAACfGwAAQh4AABQAGwLDAAAZcmxvAU87MAAAAQAAegABANUfegAVhMEAAKFEHJQXOwNEL4Moe2VpeRDDaGVkAAAIDVKAAQJyvPQ2fnNkdPYCAIT9FJAIygAAHlsA5W0NAAAGGQAAOx0AAEwfAAC4HwAAyh8AABEQAQUCwgAAErEcEBofaXkrAuAAAB55ZG7zAoLi5RQAAA0aAACxHgAAABA1AcMAABChG3PzHPQcVB4QxHN0ZW0AABASoMJ6ZQAAgBDyAAIAaXkA9bMQAABQDAEAawE8ANjrAAB7EBDEb3VudAAAShT2AADAAkgCLE9AShBudAA6MAAAAQAAAAAADwAQAAAAUBAWEm0UnRi2GLsYDRmOGY8ZkBkAGrEbfBwgHdMe6B7ygIIBdDHAAAArDSwNNRAQwmRzAABXCjKAAQI8ScBJH2xkcvYDAP0QKAH9REAgwgAAEf8v/8uRnwAB9gMAAAkCxgAAGcbSAAAgaG90EM1lcmllc2VwaXNvZGVzAADKHhDRZXh0ZXBpc29kZWZvcml0ZW0AAGkg8oJjaAIAbnMyAAECP7uuPf10Y3IA9VkQAABZABYAXAGuAgUCwAAAFxAdEukXaXKDAv0QEAXFAAAbsMgAAB2EygAAHq47MAAAAQAAJwADAB4LAABiCyMAWBQCAAUCwQAAFjEfFy4dY2QQw29wZQAArxoSgAGCxwAAH65jEMNubHkAAJkZEMJuawAAkhcgymFuZ2VkZXZlbnQAAK8eNB8SgAGBxgAAH4Fo9gAA/QRBAgLDAAAe88YAAB9zZGWCeRAAAOIJGQAAQB8AAJMfAAAwABIFb3JpdGUBhMEAAB5qc4IGDQAA5zQWAAAhGQAAQBkAAOwZAABwGwAAYB8AAJYfAABZIAAAQwAwAAQCyWFja2F1ZGlvYwAAFmFubmVsczMfE2RlY1keaG8SgWwBhMIAABubYiWBwgAAES0cABUlHxjLAADsF/IBZAEAY+EDCQAAVgoAACD2PxYAAAoGAQAjABwC8QD6AoKQAAE+HQAAdgWAA/0MAApok487dcAAABkSaWx2AAD+5ESeBMgKyIEAAAJhgwAAAmJt09nrZ5QAAAJjDWRs+VklcEFmOwIgJWWkSyTkdIYeAu3mfxRW7oIgYYU31jQoMHvo38IAAAs2bLKFcCzCAAAdF2tdv8N4bc2MPT8SrZYGNgD1XQEAAAEA0wnxEQABIccAAEwfuB8FA8AAABQZHRS8GBoJHWFpdDHBAACoGL0YER8BReFwGAAALx4AAEMFAsAAABB3HCLjF9gebHkFAsAAABENHSDGEzMeY2QQzmFjdGplbGx5ZmluaWRzAABwHmUDxQAAGKYgFmkgxGMeYWZwjRiOGAkfZB+KH3IgEM51YnRpdGxlZm9ybWF0cwAApB8Sg25hbAEBfynyWj1zZPKAAQFycPYAAAIDdf4CgyBlcml0dQA6MAAAAQAAAAAAHgAQAAAA5QOWBpcGHQtYC1kLWgtbC1wLXQtlDWYNZw1eEOMUUBZ2FqkXqherF68YLhnuGe8ZiBuNG44bhRyGHCYfmyAVgcMAABTfHpccFQHBAAAUbxlnwxkrAsYAABnZcnMQzWVudGlhbGJhY2tvZmYAACIeEoABAcAAAB0wbnIQyGN0ZWRzaXplAAAFGfKAioUQxnBsYXllcgAAeBAAT2JhY2tncm91bmRhdWRpbwHJHgAAAXUgAAASgAHAAEbEAAAYqMcAAByAwgAAGG90IcMAAHgcgh20AsN1dGUAAKRhdGNoAhkUdWVyeQQZYnGPC1KAAQLgAAAe8WNydgAIYW5kaWRhdGUAAeAAACCccwERHQAAABKDaW9uAgChAAAUSGNzGOMAALwYEMduYXBzaG90AAC1HABFZW1pZHOCsxwAAAHtHgAAIoACAJgAABeRZHQyAAIBwAAAGZ5pc2IAg3IcAAD6rQIAZGjygXUCAGRzEMllZHNzb3VyY2UAAJgZ8oACAGVs9hMA/RTBBMAYRMgAAB+jwwAAEh/CAAAQWQDn5QcAAJ4IAAB9FgAA7hgAAKEcAAAmHQAA9h4AAIkgAAATIwAAAQQBPTqHcIBj4kEZAABPHAAArh4AAAEABQLAAAAadCASWxBhbKHLAACAIAUCxgAAEEIfElogc3UrisUAACCz8wICZHPhiBgAAPceAAAQpf3AQKEGAAAU9BhBQRBzEHQQdRAzFBazGrQaRrYYjhmPGZAZFPUYGS8dTQoUgcNscnUAABRzeW5jrhp3EPKAAYJ0MoABAlMH+Tu8Y2RvAQACESHCEDXCAAAUaWZu97AIAABYDEQBWwIFAAEAGgagyGhyb3R0bGVyAABqGQBAg1MKAAABpBgAAABGbWl0dGVyhf0YAADjmRwAAPccAAAbHQAAHx0AABIQfgAmAwLCAAAcZMIAAByQZXN0bG4A6LgYAAB4HAAAixwAAJQcAACCHQAArx4AADIfAAA0HwAAbB8AABAgAAAg8oABAW5s8oCGiQFC5E0QAAC2EAAAVxQAAJYYAACmGAAAAwEABQLCAAATFxkQ/hxkcwUCwAAAFWsYG0MeY2RbwABowAAAGRk1AsEAABU4HBomH3N35xHoEVccI4ICxAAAHJFydQUCwQAAGfocW34efx6AHoEemCB0eCHCAABiDawNFQHLAAAYfSBkbR07mWXs471YFQPCAAATEhkUnBkTlxtmaWthDaHIAAC1GmsDywAAHkJlcnNRxQAATBRNFE4UTxTmHgUCwgAAEw0ZFjIcZmtVAscAABiBHxmuH2NzCRkKGQsZDBmTH/sCYXIQw25ldAAABQ0SgAGBwwAAGXlyEoCCA8AAAArezAAAH2nFAAAZx29ydQAAAf7A3/5fEeVUMwNemGCqGtEAAB/IGbjNBdE0aZeHj0H2y4FHRBCvNpcZOb2aIz299cb8RbrMWYZkFK2qaT+Or4jSbZmp+REZFxZR9QoGXvtwbtqSwcdXsDYBNnygua5oaJkMTodL5wyScQZTCStpauUydh4uR93xJGgAOjAAAAEAAAAAABYAEAAAAB4DHwMgA04D7gfQCNEIOAkJCwoLFwsjCwQNBQ0hFmwW+hhEGbkZuhlhHNMcjiAVAcQAABA8HnMhEgUCwAAAscQQtrsZbnDBwwAApBTjAP0A1AAy2QJUW/ftBwAAsRAUAFIABwZTAVMAAUb6DB0AAAkATQGaAAUANgAzAAEAZgABAAnAACzMAAAfg8UAAB6LGU1SlzMFAsEAABtRHxRSG2x2Ff7CQEBIAgAAGVAfE1gcGoofFNkcFU8dURZrA8IAABiVYWxyBQLAAAAhYB2uHxyJH2xwGwLgAAASDW53BQLAAACxfRkYkh5kdqHSAACAICsCxwAAHr1sbgUCwAAAEG4UFGEeY27zAI0BZBYAAAmCMKvFyokhxgAA6hxSHgUCwQAAG0wfGh0fYW1hwgAAJw38Ef0RFxmEG04c24/EAAAftgGBAkeHPiuewQAAGq1jbgUCwAAAQaUftR+GIJIgUbsavBpjHIMgiCBkbgXAwwBKAAAaViAXHx8UNh4lAcAAABPzGHNEDSAZBQLBAAAWQR8Y3B5udm8B/Q2QBMUAAB4rywAAH4vPAAAgd6DGZXNzYWdlAAD7GBDDaW5kAABHEBDDbXBsAABGEHIBcgMBxAAAH41pa21y4c0HAACaCAAAgupiEAAA4RQAAPkUAACdFwAAtxgAAIwZAADeGQAAAxoAAHwbAACtGwAAAh8AABARAAAAAPIAiIYBWQEAAAAFhsAAABwSH2K7ELwQRhuIHIkcihwFAsAAAFPMHs0ezh7PHp8gFp4eYmMAS2Vkc2VyaWFsaXplOzAAAAEAAHYAAQC2G3YAOzAAAAEAAHYAAQCoHXYAlgSAAcAFpWP2ibfpJAgx0CzFAAAgkcAAAB7+cwAAAv6AXz5fecPL89BqvyuqNC/GzsQ9CgXH0FUbUryC/Dd2vuLhwgAAHT4rqJBuMB6MGVB1zAAAH087QHNXIlWvgQJcFwlf1/tXljaWXiPsLaVsNpsOAVo+rucFbVbXbtxjTYCipYAx7QVEVmFyADowAAABAAAAAAC7ABAAAAD0A8YGxwbIBucH6AfpB6II+wgHCQkJMgkzCTQJXwroCukK6grrCvQK9QoYCxkLGwvMC6ANog2jDaQNpQ1JEFgQbxBwEHEQchB4EJMQlBCVECUSJhJ3FHgUeRR6FL8UwBTBFNYU5RTpFOoU6xQZFXkWehaNF5UXlxeYF6wXrRfQF3AYcRhyGHMYdBh1GIQYhRiGGOEY/Rj+GP8YABkBGR4ZSxlMGVAZaBlpGWoZaxltGZYZlxmcGcgZyRnQGdgZ4hnkGfcZ+BkBGgIaDRqxGlYbaRtsG5EbkhuUG6QbXxyOHI8cmByZHJocqBypHMYcxxzUHNUc1hzXHPcc+Bz5HPwcAB0MHRUdGx0cHR0dHx0sHVQdVR1WHXUdiB2JHYodjh0vHjAeMR4yHkYeSx5VHmEeYh5nHrEesh6+HsQe2R7uHvEe8h70Hvse/B4BHwYfDx8SHx4fIR8nHzcfPR9dH18fYx9qH2sfjB+aH54fqh+3H8kf0B/RH9IfBf3ACMABAAAYWh8RRxsToh0VHh0TAQLUAAAgoWRtAfAeAAAVAcEAABORHWsEGVsDIVwHsYNyc3UARnZhbGVudDswAAABAAB9AAIADhIBAMwTewAAEMZuYWJsZWQAALIcEMRhbmRzAACtGjKFbGl6ZXICAcgAAB/OYmVu8oCFggAFcmVzZXQAAeAAAB7bcwEEDQAAAASFxGFpbm0AABF43hQRbt8UEMxpbHRlcmVudHJpZXMAALEcCEQBAQ0AAAGwGwAA8gD9YoAIAKoKAAAAAVwQAAAFicEAADL7GfwZ+xwR1x6DBP0UEAInDHE8HcUAAByXfrMQfxf3eRYAAAEA7wTDAfIBAwCWAAWGwAAAEmgfF3EgFYLEAAAacSApHVUBwwAAoAMdc7YYjhmPGZAZABr7AmZnBQLFAACkkx4WIx9jZfuKdQHBAABTIhIjElwWXRZeFm32CvcK+Ar5CucX6Bc1HhsDzwAAIIXCAAAeSmFucmHCAAB7EHwQfRB+EH8QnBgAQIICDQAA5YMYAAB+HgAAyh4AAAgfAACNIAAAmCAAAAMAABDCb3cAAA0SoMV1bWJlcgAAlhl2AARzb2RlwABk/TWBAMcAACCmwwAAHwrDAAAfn8UAACBpwgAAGrltSQwP+gEgCwAA/lwUAAAxBAEAEwIBAAwC4AF7AFAACwAVABEA6ABFAHYcgIT9EcgHLljpiIUl5sddbMUAABTkocQAAJYZAQABxQAAHp104lkKAABMFAAA5h4AAEMAocQAAPcU8wECcXMB1wcAACHHAAAUHWAgBYbAAAAcEh8SRhsrAsYAAB6ebHQTgYXBAAANChDNb25tZW50c3VtbWFyeQAAIR4SgAEBxAAAHGFyZhDDbGFnAAAADfKAAgBmaQUDwAAAp2oZJtIcqx8S4hdob3lFA8AAABWjGhfMHxUhHmV0dVMKVApVCqQYBQLAAAASsRwg3hgaH2l5BYPAAAASkBwVsiAFisAAABPyGBLmFwUCwAAAEmQcW68fsB+xH7IftCBvebHQAABTIGHFAAChGqIarhyOHlkftyABReT9GAAAmRwAAPccAAAbHQAAHx0AAEEhAAhgARAfAAABhCAAANYAgAHEBArgAAAbQcQAABhlcgAAgg1u525eouAAAB5aywAAH4JNkvgfbMcAACB9wgAAF+REs0zJHcgAAB2kDGf5egAAE7hnaxDMjO9JIxFWF40HsTv1MhhOQ08HMGFiZGhpamxtbnJzdAA7MAAAAQAAtQAcAP0IAQA+CgQAXgsCAMkLAAD1EQAADhIBABISAQDME3sAKRYJALgYAQBwGQEAqBkCAMYZAAC9GgQAnxsAAHgcAACCHAAAixwCAJQcAAAJHQEAbR0AAIIdAABCHgAArx4AADIfAAA0HwAAbB8CALQfAAAMAtoAABRwYWNl6xcVYXJnZXT6GHN0GpoBAcQAACCQcmIEwcR1ZXVlBAgAABdvd25sb2FktRsebGF5YmFja3N0b3BwZWR9Hx1pZGVvZG93bmxvYWRzQh8Qzm5lY2FwYWJpbGl0aWVzAAAgHhKAhILHAAAbRkHEAAAMH3MfdB9lIBDDaW1lAABcDQBDc29u4UQQAADLEwAAEPPMGQAAfAElABqFgQLIAAAdgM8AACBbbG0QxmZ0cmFjawAADBIFicIAABzBHxRhGyMA/TQACMUAABuIwgAAHT7GAAAei+H0CgAAeRQAABEAQAHMBwAA99kYAACYA2AAXQEaAUEAFgDygAEBZHIQw2F0ZQAAWw32QQD9GEAF/UWhAlH0b6WOxQAAHj3MAAAeni6B0oTvyAAAHHbMAAAen/JYAQAACAHq7QMAALYGAABOCwAAHRIAAMMUAAD0FAAASxYAAL0XAADcGAAAQhwAAMUeAAABMREAEAABQDswAAABAAB/AAUAYBAAAMoQewC6HgAAsCAAALUgAAAAQmV5AZUZAAABxh4AAPICb24BAGsBCxIAAAAQxWRmaWxlAAC7GPIDeXB0hgABjgsAAADygAGCcgEBAk0AsL9YPFhVZcRwc+UUFgAAtxcAALMaAABVGwAA8xwAAFQeAAAAEBAQzGVtb3RlY29udHJvbAAAyB4Q2WludXhoYXJkd2FyZXZpZGVvZGVjb2RpbmcAALMgAECDiwsAAPRbFAAAVwhRAiAA8gJsZcAEIQCCUQoAAADygAEBYm0AAP5AE0AL/TCZCQBylhJUboPowwQLR6eycBrab90xdsiWXHJIAf8nw1LgmtQ+Sql+nMEfElcuFsoAABwwwQAACzBSEvaQGQq3grWxzAAAII4MbajbFU2WJHQWAOsjAwAAmAYAAO0IAAA7EAAASBAAAIcQAACuFAAAvxcAAOoXAAC8GAAAIh8AAJwfAAADAAAFAAAFg8EAABROHjBQHUEeWCABAYPLAAAfggR7K50rAQkdAAAFAsIAABOtHBSPHml0+wJkbjHAAAA8DYEcYiAgwAAAyweZCPIAAQF5cwAB6xgAABKAAY4g2QHKe8MAABmgywAAHk90BYzBAAAUsh4QlRsBAAEwRsGo5nM7MAAAAQAAIgAEAJYLHQAmFgAARBwBAFkcAQAEAsAAAB9ydmVycmVjb25uZWN0ZWRBHx9lZXB0aW1lcmNoYW5nZWR8H2VsEMt1ZXVlY2hhbmdlZAAAMx0Qy3RtbDVjb250cm9sAAAyHRDRb25uZWN0aXZpdHljaGFuZ2UAAHsfENBhY2tlbmRpbml0ZmFpbGVkAAB6H/YBAMAjUAF1c9u2GnSCfAQAAAC2AIAB/RggAn1SbC9Z0gAAIKF0NoIAAv0XYA9TUKMzIMQAABxcCYd/hDI8QRb6eVr+Kt0jcqw6/+EbrTlsfT2sz9YgaXAAOjAAAAEAAAAAADkAEAAAAG4GbwZwBnEGXQ1eDV8NYA1kDXsNURBSEFMQsRC6EMIQARIEEgUSEBI7E9EU2hTbFPYUIxWUF6YX5RfnGPgYdRl2GesZsBq4GrkaURxSHFgcYBxoHK0c/xwTHUMdRB1FHUYdUx1dHV4dpx0/Hlse+R6oH2kgFf7BAAxhAAAAEi4ZE7EbFqYdx5UeFIsfShtLi8gAAB2lAEh2vkMF/cEEEQIAABhhHxNpIBOkHxO+GQFSOzAAAAEAAHoAAQDVH3oABQLAAACk8xwXGh9ldDvEAAUv/8uRnyUBwgAAE/MYc0QNIBkBQzswAAABAAB/AAUAYBAAAMoQewC6HgAAsCAAALUgAADzgQJlchDWZWN0aXZlc3RyZWFtaW5ncXVhbGl0eQAAaCCWKYAB/RXpAw9KahpCwgAACzbCAAAdFuAAAB6LwgAAHRfhAAAYn2YAAMUIvv7C9xNVQnO5QkINERTK0gkBgPrHI/H46/NiMIaMhRLrVU3dOtKZ4mxEPcEf7TqaBpj7a8CvRLBsXJpoMHa0fF5LOt+JiadFj/6a5lmrLUYlG0kqA9oTrdov7gio43uoJnJu9OAIItmtDnIIaogPBSVwbbcUy/3mw3RRoIOpQ4F1qygx32eEsQA7MAAAAQAAVggwAV8BAADdAwMA5gMBAPEDAgB8BhkAqgYDALgGAQDJBwAA1wcAAOEHAgDqBwIA8wcAAPYHAgADCAIACQiMAMIIDQDSCAEA1QgAANwICwDpCAMA8AgAAPUIAAAGCQAACAkAAA0JBgAkCQMASAoBAE0KAQBRCgEAXgoAAGIKegDhCgEA5AoDAPEKAgAFCwAADQsJABoLAAAgCwAALwsAADkLAABSCwIAVwsAAIgLAQCPCwIAtwsJAMMLAwDTCwAA1QsAANoLBgDkCwMA6QsAAPUMAQD5DAQABg0BACENAQAkDQAAWw0BAGINAABwDQIApw0BAKwNAwC5DQAAMhACADYQAABNEAAAUBAAAFYQAQBZEAIAXRAAAGAQAABkEAIAaBAAAG0QAACAEAEAiBAAAIwQBgCiEAwAshAAALYQAwDEEAIAyBB9AFURdgDnEQEA/hEAAAYSAAAIEgAAERIAABYSAAAYEgAAIhICACoSAAAxEhAAQxIAAMgTAgBXFAAAXBQAAGMUAQBpFAAAbRQAAHMUAwB7FCMAohQBAKgUAAC2FAYAzxQAANIUAADcFAAA4hQAAOwUAwDzFAAA+xQCAP8UAQADFQAACRUAAA0VBAAbFQEAJBXwACQWAAA0FgQAPhYAAEUWAgBOFgEAWxYDAGUWBQBtFggAexYBAIEWAgCIFvUAlhcAAJ8XAQCjFwEAtxcAALwXAAC+FwAA0RcBANQXAADiFwEA5hcAAOkXAADrFwAAbxgAAHYYAwCAGAAAixgDAJUYAgCdGAAAoxgAAKYYAAC2GAAAuxgAAL4YAQDXGAAA7BgAAPYYAQD7GAEABRkAAA0ZAAAhGQMALRkAADAZAQAzGRAASRkBAE0ZAQBTGQEAXhkCAHcZAQB9GQQAjhkCAJgZAACaGQAAohkAAKUZAACnGQAAwRkAAMoZAQDPGQAA0xkAANoZAgDfGQIA7BkAAPMZAQD7GQEAABoAAAoaAAAMGgAAmRoBAKEaAQCsGgAArxoAALMaAQC3GgAAuhoAAMYaegBTGwIAcBsDAHUbAACBGwAAhRsCAIkbAACbGwAAsRsBALYbdgBGHAIATRwAAE8cAQBXHAAAahwAAHscAwCQHAAAmxwBAK4cAADSHAAA2RwAANscAADrHAAA8RwAAPMcAQD6HAEAER0AABkdAAAgHQAAPR0AAEIdAABIHQAAUB0AAFcdAABaHQAAZh0AAHkdAACGHQAAlB0AAJwdAQCoHXYAIx4AACseAAA0HgAANh4AADkeAABBHgAAVB4AAFYeAABeHgAAaR4AAIweAACOHgAAkh4AAJ0eAACuHgAAuB4AALoeAgDBHgAA0x4CANgeAADdHgAA6B4AAPAeAAD4HgAA+h4AAP0eAAAEHwEACR8AABkfAAAlHwAALx8AADUfAAA7HwEAQx8BAFkfAABbHwAAXh8AAGAfAABkHwMAeR8AAHsfAACFHwAAih8BAI0fAACWHwAAnR8AAKUfAACnHwAAqx8AALUfAADAHwAAxh8AAMgfAADPHwAA1R96AFIgAABXIAIAXCAAAF8gAABmIAAAciAAAHwgAAB+IAAAhiAAAJAgAACSIAAAnSAAAKcgAACwIAAAtSAAALcgAAABAAHIAAAdJGQ7MAAAAQAAeQAEAPsDdgDvBwAAJRYAABIdAAAFjcAAAB+zIBcfHjRoGWkZ8h4VAsMAABihIBZYHmRylBkBAwEGqhZ5/XThGgsAANEXAAABO43DAAAZLSHEAACoFDkeBcLAEEAAABGCGBOlGRVLHEuLxwAAHHbLAAAfDBWEwwAAE+sZhxgVAsUAACRXHlAgFvwec3fkGfuDIcUAAFQZ6xwVhMAAABx0HxcLKwLKAAAenm50G5LCAAAZ9cHIAABXG3UBwAAAEIUgc6wZFR8WHxcfGB9aIKQgEMZlY29uZHMAALQbEMVybGFzdAAAsxugwXMAACIWEMdyb21zZWNzAAAxHfIEdGlvbv0gUAIA4f8MAABLDQAAD/UbGQAARgBuAzgAJwDygIEBbDYAgAH9FIgA4AAAGG/FAAAfg8UAAB9RciHAAABfEEwcEMVhcmdldAAAIRYSAAGEar3q4EN04dgDAAB3BAAABAH3DAAAUoABAsAAABj5cG93AEABdgQAAAHIHAAAEMh5bmNxdWV1ZQAAshsQxWVwb3J0AADdFBDPZW5kaW5nZmF2b3JpdGVzAABAH/IBbsAAaAAA40kRAADKFAAA8x4AACgfAAARADKAAgHAAAAQsGl3bjKAiYIiPLXNEgEAAc8AAB9wdOEdDQAARxsAABAFAsAAAB2yIBFsEGNlBQLAAAAXbhkUDBJmdKHDAAC0GEHGAACqHKscrBxkIAUCwAAAFK4bFTAfYWxlAcIAABCFIHMVHxYfFx8YH1ogpCAARHJrZXKCaBkAAAHyHgAAEMd0aHJldHJ5AAAfHvKAjAAUAcRpZGVvAAAWZXF1ZXN0WB5ylBkQw2FzawAAkBcAQnRzAboYAAABDR8AABKAgYjIAAAgURDHZXNwb25zZQAAfhwQxHVyY2UAAKwaEMNpZXMAAKsaGOEAAKoaMoACAcUAAB9nYXJtEM9uY3VycmVudG5ldHdvcmsAALIg8gZsbG93ZWSIAAAByx4AAPYBAP0RQAWUxgAAIILDAAAfDwF2EAAA+ggZAACYAs0CBABSAEsANAAYACMBJAAAgIgBmQAAGPngAAAcTHcQxHN1bHQAAGcZ8oCKABDGcmFwcGVyAAD8HgBEdGF0cwFXHgAAAVAgAADyBmFuYWdlcgIAc3cB5BkAAAAQxmVxdWVzdAAAhR0UAcduZHN0YXJ0AAAWZXF1ZXN0YSByPx/yAmVtAgBhcgH4GAAAABDCZm8AAI8XYoDABIQApgAAFhqgw2VudAAAuBgAQ3JvcgG3GAAAAQIfAAAxyAAAfx+AH64gEMJ0ZQAAVh7yBHRlbXMBAGMA4XceAACWIAAAEPIAhAFsAOGmGwAAJB0AACDygMEEIAAASGlza3VzYWdlAX0cAADhQx8AAKcgAAAQoMR5dGVzAACSGRDDYnVtAACRGRKAAQHCAAAdKmxzAANvYWT+wEaEGwR4kfXXmUOz0waIBKR17SAyfZ0722Xjd1Z1ArCmR88n4gPdVHlgagSWcehsnEM0zuH1igpfIDJnxwAAH0fEAAAeicMAAB45b36IMfpjZnF1hkUNAAD+qhgAAAQBIwA3AAEABQCWAL4ACABLADUBQQAnASMB8AABwALAACcoKWwA9bgNAADjEQcABwD1APIAAQBuAPJLHAAAtAKgw2FpbgAATwoATnJhc2Vkc2VyaWFsaXplOzAAAAEAAHYAAQCoHXYAAPCAA8AIDAABAi4vMGVtdwECAXcjFJZfcwERHQAAEwAC4AAAF5Fhc4caFgAAoc0AAEUfAYECzAAAH6Zo+oPn4W52AERpdGxlgvYYAAABpx8AABIDbGF5AYTBAAAcyXQBHwsAAAAAA2lzcwABxQAAHx1zAYoLAAAAAANibGUAAcYAAB9MaoKICwAAAPYCgAPAgkAgqOjoVWFtcKDCcmwAAEIQqMAAAK8c8gV0cmVhbZAAAY4XAACDKR8AABQBxnJvZmlsZQAAEG8ec3wc+gYBAHDyChIAAA8Eg6IeAADygmN0AwBwc3XyAIIAAPW2CwAAuAReBAkDxgZVAcMAABCPHnNWEFcQcxR0FHUUBQLAAAAiTx+XHzTpFOoUhBhhbAUCwgAAGNYeEsIac3VBwwAA5xToFMIfViDzgQJhY6HDAABUEAUCwAAAoHMNclEQUhBTEOsZuBqtHKgfZHTE/cdub3N0aWNzUgECAAAVdW5kbGV7HBV4cG9ydDAdFmV0aW5mb24eE25mb+MZF2V0bGV2ZWzHHhUW1hMAAf0EjQfFAAAfLMgAABnqVL48YQNnAOS0CAAAiRAAAIcYAADyGAAAoRkAAHAAAPYAgAP9CGAEd6C1GCQszytVjxFQ6YWGYXJzBQLAAAAabB7DYx5sbxUBwgAApDgddLkaKwLLAAAgg3N0JYjEAAATpiChH8EfVYLCAAAWHx24GLkYrx40H2wfEMlvdGFsYnl0ZXMAAC8dEMRldGlkAAD1GEjFAAC2GI4ZjxmQGQBDYW1lARQWAACCsxoAAEDBZAAAQRBzEHQQdRAYwwAA9BjyAmNl/UChBgCCTQoAAADygISCBQLIAAAUlCAVnSBscqDVbWluZXZpZGVvc2Vla3N0cmF0ZWd5AACAIBDMdGF1ZGlvc3lzdGVtAACnHfKAAgBjcgQCwAAAEWhKCqFsSwpjafKAgwFyAENyb3mChgsAAPJbHQAAWwIVAcAAABuIIHTTHBDEd25lZAAAgBsSBmlhbGl6ZQIAoAAAF41vcjswAAABAADwAAIA3BQAACQV7wAA8oABAXJ08gACAGV0AOYCDQAAgxgAAH4eAADKHgAACB8AAI0gAACYIAAAEDAAADHAAAB5GcAZdCAhwgAAtxgCHxDOZWVuY3J5cHRpb25rZXkAAMYeEoABAcAAAA0Qdm5UBgCE/TRACwDFAAAbiLzCAAAdPsAAABINAgPGAAAeiwDvBwkAAAkJAABfCgAA9AoAABsLAACTEAAAeRQAAL8UAAAZFQAAUBkAANAZAACxGgAAqBwAAPwcAAAPHwAAEh8AAAABAhIAAAAAIcQAAF4gsSAFAsEAABGZHRLyGW1zBIrAAAAWeHR1cmVzqRoTa2Vu6hfAx2xheWxpc3QAAKUaAEN0ZW0BIxUAAAH5HgAABALBbQAAE2lsZbEbFmV5cmluZ6YdZmsQyW1lYnVmZmVycwAApR3ygXKJABQBx293bmxvYWQAABV1bmRlcg8fc6QaBALBbAAAG3VtZG93bmxvYWRzDh8ZZG93bmxvYWRzbR5ibP4DJv0pgQQCxAAAHvTCAAAeRnN1AUkKAAABVBsAAHKAggPhAAAK32FpcwBTbml0aW9ubmFtZWRkYXRhdHlwZTswAAABAAB6AAEA1R96AAAQw2VlawAA8xjyBHJyZWQBAHMBRA0AAAEgGQAABALAAAAXbWVvdXRtcxAdEnVl6RdpchDLZW1vdGV2b2x1bWUAAK4eEMlheHJldHJpZXMAAIQdEMZxYmFuZHMAALAb8gN1bHT9EBAFADswAAABAAAkAAIAHgsAAGILIwCDWBQAAHKAh4jEAAAgaTHAAABhG3wdaB4FAsAAACShH8EfEXQgYXIbhsEAACCzGwLBAAAfCmR1ocUAAIwdMcsAAP0eGR9cIAQCwAAAG21lbnRlcGlzb2RlrhwScHRhC2V5scMAAHcbEM1lcGxheWJhY2traW5kAABsHvKAAQFkc/YGAAL9gUgCxAAAHzFpcgDklggAABMVAACEFwAAZB0AAFkeAAAiQQAQwnVnAADKBwAAwQvh/dnxIDAKgOz1Q0ti5WRdwQDGyDmVu9z9cZEQFC1L/TcCIw3AsqbxZpl1/AdJ8tIchh3f2lE7wwAAH58w+FDlPcQAABi8ApmIfeIRO/0HZRi+OtFRV8CGuFUcAkmMs2SpgXgGADowAAABAAAAAAAqABAAAAB8Bn0GfgYgC2INrA1oECISIxJcFFwWXRZeFqMXpBfmF40YjhijGDMZNBk1GTYZfRl+GX8Z0xmhGqIarhyOHgQfCR9ZH2QfeR+KH6UftR9yIIYgkiC3IBUBwQAAoJIZc4Eb8wABdAGeGQAAIcEAAJQL6wtAxmVydmljZQAAyBPJE8oTCRUQxWVzdWx0AAD+DPIAAgJyc2l5AAF0GwAAIcMAACod3x4VAcMAABWaHXSRGTHCAAB9G6QciB8FAsAAABAfDRZcHGRtVwAB/gDkQAHFAAAZwMgAAB1ZyQAAHjzHAAAc6QHwCgAAFQHCAAAQrR9zfx0VAsAAABdTHh7NH2NtnCAFAsIAABN0Gx5gIGFrpQHAAAAYoh9j+RkFmcAAABZDHhRbGR7MHxDDaW1lAAD9DNYkAAH9PYgDyAAAHXrDAAAclMAAAB6LdeaV7unOAAAfoHQA5VsNAAAwGQAAOxkAAHcZAABGHAAAER0AAAAxEABDeXBlAfkMAAA7MAAAAQAA7AEEAIgW9QDXGAAAxhp6ANUfegC0AsNhc2UAAKZlcnZpY2XgGRZyYXBwZXLiGXN3+gzyAAIAYnQA+CcNAADVBAEAGgdtAsoAxgBMA/IAg4QA9y0NAACaAwAD+wAaA8cBTQO2BIAB/SQoC8UAABoNxAAAGR/AAAASB1w4awETdAAA/RNBCcs+30+XvwssLmEELuUnT70HJjWouzTufjMDARIA4NfTw1FGNLscfktMQBwq6ZBERx2z7X4XNkmVl5sG/2N3Vw3x2PDyYyxLKFcSN9k4gDBE0as5EeSJ8mU48smKSji3PtXjKZgFujktco8ixVI7CByXADswAAABAAAjBbgANwAgAFMBAABYAQAAYAEAAGcB9gBgAgAATwN6AOQDAADtAwAAcgYGALYGAQDMBwAA8AcAALwIBQADCQIAGQkEACIJAQA1CQAAOwkAADwKAABWCgAAYQoAAN4KAADsCgAA7woAAAMLAAA9CwUATgsDAIsLAgCUCwAAlgsdAMcLAQDiCwAA6wv2AOMMDQAIDQAADA0CAB8NAQAjDQAAKw0BAC4NDQBEDQYAaA0DAHMNAgCmDQAAug2LADUQAAA8EAEAPxACAEcQAABUEAEAbBAAAHMQAgCWEAoAvRAEAEsRBADMERYA5BECAOsRAAD6EQEAGRIHACsSAwAwEgAAOhMAAMYTAABbFAAAZRQAAGoUAgCtFAAAtBQBAMMUAQDnFAEA9BQBABoWBgAmFgAAOxYBAD8WBQBLFgAAVBYEAJoXAQCiFwAApxcBALkXAQC9FwAA7Rd3AHoYAwB/GAAAjxgAAJMYAACoGAAAqhgAAL0YAADZGAAA3BgBAOkYAQD0GAEAIBkAACUZAAAyGQAAVRkAAGUZAQBvGQAAdBkAAHsZAQCgGQAApBkAAKYZAACuGQAA0RkAAPAZAQD1GQAA+RkBAAgaAQAOGgAApBoAAMMaAgBBGwAASxsGAGIbAABqGwAAgBsAAJYbAACZGwEAnRsBAKYbAgC1GwAAQhwAAEQcAQBJHAEAWRwBAG0cAABxHAMAlRwCALIcAADEHAEA0RwAAOocAAAGHQAAJB0AACsdAAAzHQAAcR0AAIwdAQCkHQAALh4AADMeAAA9HgAASB4BAEweAQBSHgAAbB4AAHkeAACGHgAAiR4AAJAeAACeHgEAqh4AAKweAAC9HgAAxR4AAMseAAADHwAAER8AABwfAAAjHwAALR8AADAfAQBBHwAARx8BAEsfAABTHwQAcR8AAHUfAAB6HwAAfB8BAIkfAACfHwAAph8AAMIfAQDOHwAA0x8AAFYgAABbIAAAqCAAABX9xAwRAAAAG2wfFCgfE+cYF04eshsFAsMAADHlGOYY1xkWqR5vcBOBAsMAABjtY2dLA8oAAB6NXyrJjZdhbHIVAcgAABQuH2NsHBsCN1YMOUtlcqHEAADKFAWMwAAAFUIdEgISMcUAAJcXmBceGQBIbGVyZXBlYXSC5xcAAAE1HgAAUgABAsEAABBeY2VwAOEgCQAApRwAAAEQw29pZAAAPAq1AsEAAKQCGRQEGWJxjwsFA8MAABYiHRcoHRcpHWJpcMDDdG9tAAD/CCHIAAB6GW8gEMVzc2lvbgAAoxoSgIIBxgAAH8x0BIrAAAATZGlh8hgSZGXmFxDJZWxseWZpbmlkAACkHQh+AeUXAAABYBwAABDMYXVkaW9lcGlzb2RlAAB5HwQCwAAAwmV47hgXZXJ2YWxtc6MdZHTygMAAxAAQw2ludAAA5BcARnBpc29kZYKhGgAA844eAADLAF4B9gMDZW50/ZATAgLHAAAgfcUAACCyZG6DXgsAAOKoGQAAbR0AALQfAAAgADKAAQLCAAAQyXJpc/KAAgJyc2x0BQLBAAChLRKgQhBlbCHHAACpHAYfscsAAOkeRZDEAACirxyOFykfKh8rH2sDxgAAHOlldHU1gcAAABHEHz0JtRq2GvMAAXOCThAAABsCxAAAGdhycxWBwgAAFK4adxAFAsAAABlwHhhZHWVzJQHAAAATBRlzHw1BHxMAAsUAABxcZG0BVh4AAAUCwwAAFacdtGEdc3RLA8UAABnQwAAADSNiaXUAQAFfAgAAAdYXAAAAAAH+QOTyBxzEHct0IkpHVCMqsUWvX8UAABnAR9asYjVOvZ1mhwGPc6vTKENdO4AQNDMl0skAAB48xwAAH6YP+m4zQimjL3CyN1zyyXJ4APLwCgAAEgoVAcIAACKVGcYeawsSIwKGxAAAGLvSYQsAAC0Qx3VyYXRpb24AAC4dEoZzc2ZhZGUBAcUAAB8xZGMFwMBgEAAAFnkZF5cbGJEcBQLAAAAU4hQSeRxwdQWDwwAAHIIfFq4cFAHDb3JlAAAWcmFwcGVy+x533xkAQ2F0aAGvGwAAAXYeAAAY7QAAVB7yA2lsZQEAbgDS0x4AABXyAMAIiACCIRUAAAAY0wAAfxsQxHJyb3IAAN4Z8oZlbnRpYWwDAGVyc6HIAADRHsQBw2lzdAAAF2VzcG9uc2X6HnKdGhDIcmJhY2tlbmQAAMUe+roCAGVsEMhlZGlhaXRlbQAArRwAgIEBmQAAE8fEAAAet3L6JcAECgFzAUgKAADyUxsAAJ4BtgOAAv0QEAZ9rObvImFk8oCKAXkBggNKxLc+osQAAB7c4AAAGLJhc3kVAsEAALR2HRdbG2Nw7wpFiMEAABUCFdMIAxVDGa8aoMNpY2sAALQYAEZlY29uZHODqhwAAAFkIAAAEMRuY2VsAACuGxKAgQHEAAAfMGzyA293bsACwAAA1JsfAAAHB/XyAm50AQBkADowAAABAAAAAAAfABAAAABKFGYUZxSfFKAUoRQMFToWYBZhFmIWgRfFF8YXxxfsF5EYmBiZGJsZmxpcG3Yddx14HZMelB6aHgofLh+CH48gRQHGAAAYfSBkqBmpGaoZbR0QzWlja3N0b3NlY29uZHMAAHgfBIPAAAAUcmFtc40ZHXJjZW50dG92b2x1bWWrH/KAAgBwdMDEaW9ucwAAHRXygmVyAgBzdKDGb21tYW5kAABlGRICb2wBAQ09W4AvY2wA9jIdAABeADgBZAA2AAEBCGMB4xcAAAHYHgAAAEFkAcYTAAABMx4AAPIBdAIAaXThHQsAAFgLAAAF6OMUAAB2FgAAqRcAAO4ZAACIGwAAjRsAAIUcAAAmHwAAmyAAAAAhARAAGMIAAOIX+gEBAHQA4SkWAACCHAAAQPKAAgBueAAEaW5lcgAB4AAAHUlzhG8QAAABdRgAAPKAAwBhZXIAR29zZXJ2ZXIBqRwAAAEGHwAAEMtwbW9uaXRvcmluZwAAcyAEAsAAABt0bW9uaXRvcmluZ38gEnVzDx1ydPKAiQAQynRzZXJ2ZXJ1cmwAAGcg8oACAGV0AEdlcG9ydGVy0lUeAABvAckfAAAQxnJhcHBlcgAA0B/yBW5pdG9yAQB30oMdAADoAZkfAAAEAsJyawAAGGVhY2hhYmxlZiAabnJlYWNoYWJsZX4gcnXygIkAEMhldHN0YXR1cwAAPh8QyGNrc2VydmVyAACqHxDEdmVudAAAMh/yA25nZYIAAAF7HwAA8oFogwDyA2l0ecASYgCF3RcAAAELHQAAFILBbgAAFHJyb3KtGwkS8oACAG92MoJjdAICwAAAH0HHAAAfpml0ZXLyAIIAg3MEAAAABALBZQAAGXVldWVkcGF0aKIdFW9yb290nBpxdAAAAJDJAAAfuOFDCgAARgsAAEDpFBIAAC8SAABLFAAA1xQAAFsZAAByGQAAFB8AAE0fAABSHwAAzR8AABACAAAAMoFpAgHGAAAe/2ducvYBgMEIhAIeyaESMGNkIcIAAGsYUR8Qz3RlZG93bmxvYWRzdGF0cwAADR8QzWVwb3J0cG9zaXRpb24AANQf8gJvbgEAcgAB7xwAABDEb3VudAAAmxoiAAIAmQAAF9xhY4I/EAAA9KYZAACnBOAAGgDyAAEAZAABNR8AAPKCZXSGAASDwXIAABNibGUIEhBXC3KAA4jDAAAZxmFsdRUBxwAAEK0fc38dwMpuaXR5cmF0aW5nAACpGwBAgkINAAABdB0AABDDdGVtAADbFBDCcmcAAAcS8gJuZAMAYWlzADswAAABAAB+AAYA7Rd3AJMYAABlGQEAPR4AAJ4eAQBbIAAA8oCRgrKAAobgAAAZwW1wCF2CmRoAAAEjHgAA8gZlY3Rpb24BAHQAAYIZAAASAAGIywAAHlpsAAGfDQAAAQICF5PcJCPBAAAbb3B14socAAClHgAAsyAAAEIAIcQAAKEfwR/wAAABsnODlggAAOMTFQAAhBcAAGQdAABZHgAAJBAyAAEDxgAAH8bAAAAgdGNhZHIA82gQAABrCTEF8oCChPKA/Qg4CMEAKAXAwgoIAAAYTR4VbRwZqB4AQAFeAgAAAU0WAADygAGBcLjNAACTCABHb3VuaW5pdDswAAABAACKAAIAsxgAAA8aiQAAAENudG87MAAAAQAAiQABADwTiQAA+gICAGl0OzAAAAEAAIoAAgDJBwAACQiJAADygAIAbnMAQ2VudOH9CAAAPgoAABTlEhIAAC4WAABwGQAAvRoAAJ8bAABCHgAAFBQAMcUAAOcX6Bc1HgBLYXRjaGhpc3RvcnmEnh0AAAGLIAAAEM10YWxlZG93bmxvYWRzAADDHhDLZXBvcnRlZHRpbWUAAJ0dEMR1ZXVlAAAcFRDWbGF5YmFja3F1YWxpdHlvdmVycmlkZQAAkiAYzgAAOxMARGFjaGUBGxUAAAH4HgAA9gcA/gBB4BGQwgAAG2yDBggAAAASgAEBygAAHy1ybvKAgQFyBALCc2kAABJhbD4QFnllcnJvcowZY2ahwQAAMB8Qz2F4YXVkaW9jaGFubmVscwAAdx/ygXABgm0Qy3RyZWFtcmVzdW1lAACcHRKBbQGCygAAHt9z8oADAGltc/YAgI+QYdenLQEFAsEAAMVCGxb2GXJzoIABA5kAAAgCAMwAAB5szG5hZHMFAsEAABrKHhZgHWl2KwPCAAAVA8EAABJDY2VpFQHCAAAwOh47Hooec7gXocQAAGUf84ECcHUrg8EAABZjBYPBAAAknBcmGRKeGBWEwQAAE9gYOh0bA8QAABjiyQAAIJBvcHMFAsAAACFzHb8eE6EebnRRxgAAnh2fHaAdoR2LIBDEa2xlbgAAOxASgAEByAAAHJRucAUCwAAAEl8dHT0fbXQFAsAAAHPXFNgU2RQUH00fUh/NH6KTHm51BQLBAAAQMxYXOB9ndBDLZWFjaGFiaWxpdHkAAC0dBALAAAAecmxvY2FsZG93bmxvYWR1Hx9hbWVidWZmZXJzdGF0dXN2H29ytgAAAgPAAAAYf8QAAB+qZnJlaXMAAaMfAADygAGIaxYBAAH+QEZUGcMAAB1wdUM+OCfFAAAfI3MZmSUVA63Y+0TDAAAe8CHZm49kxgAAHJAQLhrMtmMA6QMIAAAqEgAAGxUAAHsWAABJGQAAgBkAAJ0eAAD4HgAAWx8AAHwgAAAgAQEAABjIAADaFBICZWyEATJQQ+ugcwEcCwAAAeYUAAAxxAAArx40H2wfEMphdm9yaXRlaWRzAABqHvIAAYJmAOenFwAAuRcAAOkYAAB7GQAAlRwAADMdAACMHQAAfB8AABEREBAaEgGCwwAAHzJkAAF7HwAA8oACAGduEoABAwqbt1G54AAACjvHAAAfv25icnUAAJP+AGH8Az4urIKYUf/m5D8lb2eT0ccAABz1Ekd4bg86WptZWcMAAB5a0AAAIGkaeKPDrsQAABlKe3ycMVUN5BpKeDBUvFXKwgAAFmQA5+8IAAAtCQAARAkAAEoKAAD/CgAAAhkAAK0ZAADyHAAAAQAxAAGBAkwNs/6YxgAAHS9udEHGAAC2GI4ZjxmQGSUCwAAAXK8fsB+xH7IftCAn0hyrH2x0yQsKHUHCAAAUFrcXsxq0GvsCYXRxwQAAQRBzEHQQdRDDGsQaxRpFgcAAABGjGZYQlxBEFkkeIckAACwdtx8h0AAAqR+eIDHBAAA8ED0QMBIQw2xhcgAA+AzyAJCCAAE2CwAANgEAAf0JQALGAAAaDsEAAA0PbADyPQoAAHERVi4AAf1aoRbFAAAZz8cAABmYwwAAGPRs7SPCKckAABzobAA7MAAAAQAAogAeANUIAAAGCQAATQoBAAULAAAaCwAA1QsAAGAQAADKEHsAyBMCAOwUAwAJFQAAlhcAAL4XAADRFwEA6xcAADEZAABfGQEAmBkAAOAZAQDzGQEADBoAAKwaAACJGwAAhh0AALoeAAA8HwAAxh8AAFcgAACwIAAAtSAAAAUCwAAAEy8SEo8gbnUFAsIAABstHxRsG25yJQHCAAB0vxfAF8EXwhfDF8QXnB906AvCGRDCYXIAADsK8oABA2hlbG9VA8IAAKRtHxlvHxaTHWVpdmIZYxlkGQcfOh8JAsEAABxCWQ+YJNJlcyMAAswAAB/PYmQBlB0AACDFdGF0dXMAAHocmx0QxWVzdWx0AAB5HDKDeW5jAgHBAAAes3JzaxDHZW10eXBlcwAADh0EAsAAABJjaw0dEWx3HGl0EMlpcnN0Y2hlY2sAAKMfKNkAAHgcgh0SgAEB4AAAHvF2cvKDZGV4AwBlZnTygAIAbnQyA2xvZwIBA7I27udpc2KCVQsAAOIFHQAANx4AAFwfAAABAHKAgQPEAAAcgWVpdAQCwXQAABVjdGl2ZQYSGGlzY292ZXJ5shhhZABJYWJpbGl0aWVz4YwXAADXFwAABNMgHgAAPfEEAsAAABJla0ENG2FydGRvd25sb2FkKx1ldCHJAAChH8EfEMt3bmxvYWRhc3luYwAAKh0Qx2FubGltaXQAAFMe8gABAGMAAZwgAADyBGRhdGUBAHMAAREdAADygIyCAElsZWVwdGltZXIBLB0AAAG3HwAAMMJlZAAAPBA9EDASEMdvd25sb2FkAAAOGgBQdXRvcGxheWNvdW50ZG93bgGpHwAAAZ4gAAD6Ff0JCAIAAT0KAAABrhsAAHKAwAZAAcMAAB5TbCHDAABQEG0UEMJ0eAAA1hfyBGJhY2sBAGMAAUMeAAAEA8RsYXRlAAAWYWNrb2ZmIh0XbnRlcnZhbCgdF3JvZ3Jlc3MpHWJpcBDHcm9ncmVzcwAA7RjygAIAcHW2AAAC/Sg1CgKWajakwwAAH2PBAAANC2sINc6myAAAGek9vxcSW8EAABrCY2wA5NoHAAD7BwAAPhAAAAgdAACUIAAAIAAAIcUAAJgcvh4QyXRodGltZW91dAAAJx3ygIQBcjHDAAD9GZId0B4QxGJkaXIAANUXUoCQAsEAABmaaXQQzWVtc2Zyb21zZXJ2ZXIAAD0fEoABAcEAAB1fdG0QxHJzb24AAIsXEoCCgcEAABqyEMZpYnJhcnkAALEYEMpvbWVkaWFpdGVtAACoHxDFZXJzb24AAH4b8gJlbQIAcHSCBBIAAIJdHQAAAEABbhAAAAGbHgAA8oACAHJ0EMpsYnVtdHJhY2tzAACaHfIA/QGJAAAA4XoYAACqHgAAEKHCAACTHgBDZmlng9cUAADUFB8AADkFe/KBbwGQbvYBAmhl/QwBEgTDAAAdcMUAAB8jA63Y+0TDAAAe8GFlb3GDAwgAAPsqEgAA8QJgAQEAzQI3AAEAHAVbAGMAIQH2A4DAAs0BxAAAHDdkAAD+QGWWTsCdWGcjHQ9LO3qv1t9f7mE/+SIQMBw/P5FsLUsATTCBEgFBdTCMP0uaAiBeWqZtMzHCq98sVPeoiVodA2ha3SAH/w18JLR3+I0eMoQJpskAAB0FILegmHcuj2RxumvigsvYBjWdNoFweidMOgA6MAAAAQAAAAAAHAAQAAAABwBVAWQBZQHwA8MGxAbkB5YIlwiYCG4UExUUFRUVhBeFF4YXhxeIF74ZvxmuGiodZB1lHVke3x6CIBUCxAAAFWgeo50bY2x7HRMDAswAAB+hYWMB9AwAAAGDgj5JXRPyFQHEAAAXoBxwTRlBwgAAyBPJE8oTCRUVAsEAABfPHBOgF2R0dQ3zgoY7A8MAABBnZW90AYH8lgFJGyzA0lEl0D404AAAB9/BAAALJOAAAAfgFQHAAAAWrR5yhx8jAQLFAAAdfXJzARwfAAAQw3NlZAAAOhOwyW93bmxvYWRlZAAAphvyAAIAZHUA5r0UAAA9FgAAfhcAAJIZAACrGQAAbxwAAC8dAAAAEQEA+iYBAHMAAYEbAABWAAAB/QGhAMIAABxcygAAH88SI2i7hHQA8uMLAAC7DRDNZXh0dXBlbmRwb2ludAAADB8Qx2dwbHVnaW4AAKUbENF0ZXN0aXRlbXNlbmRwb2ludAAAZSDygIkAEM9ldGl0ZW1zZW5kcG9pbnQAAHQfBALAAAAfdm9yaXRlc2VuZHBvaW50cx8cc3ByZWZpeHF1ZXJ5Cx9hdABBcgEbCwAAAdAZAAAQx2hoZWFkZXIAAKgcENdpb29ubHlzdHJlYW11cmxmb3J2aWRlbwAAtiDygAIAZHQQxmZpbHRlcgAADRrygAIAZnXygmxk/XEoAACgwm5nAAA5EBICZXKEwALAxgAAHzUHkMf/WccAAB8RAPJtGQAANwLyAAEAZgABMQsAADYAgALAECbgAAAHynZVgnbFwgAAHHvCAAAfYWZpAEJ1dDswAAABAAD2AAIAOBAAAEQS9QAA8gNyb3cBAG07MAAAAQAA9gACAPwIAABFCfUAACHBAAA3EAcVAEDS1wMAAJsBAhIAAPKAAQFscwBBeTswAAABAAB2AAEA+wN2AAESHQAAcgD9CEAhAKEAAAFWAAFjAgAAAULkjBcAANcXAAAgHgAAXR4AAE4fAAAEAAATggLgAAAdLWl5JQHAAAASSBBklAvrCwBEcmF0ZYNSCwAA5UYWAADsGAAAXhkAAM8ZAABCHQAA1B4AABAAAQQCwWQAABpyYW1lYnVmZmVypBsWZXh0dXJl1BdmdPKAAgJudGRsAQCSwwAAHSXNAAAfg+NwGAAASxkAAJYZAAAvHgAAQRMQy25nc3Rvc2VyaWVzAACnHPIAiAAAhKwIAAAQ0mludmlkZW9wbGF5c2Vzc2lvbgAAch/ygAIBZ2xyEMNpemUAAEMS8gJjaAEAcwCCAhkAABDFYm9vc3QAADcQEMJybAAAQA2hxAAA4BkQxG9uZHMAANMX+pcBAWNyEMtldHJ5ZGVsYXltcwAAmR1yAAMBxQAAGeJyc3V3AOP6DAAAwR4AAMgfAABfIAAAIADygAIAZXMFAsAAABNsEDABDa0asBtvcxjOAAD7CPKAAQFuZAEGAcUAAB82cuSQGwAAjh8AAIcgAACiIAAAryAAAAQAAAUCxgAAEZEepEkfbXT7Am9yJQHBAAAXaB9zIh0iHhsCZRlXYcxmcAEDAVUh6seecuKhFwAAthwAAHkgAAADADUCxQAAETMeE9geaXTuGe8ZmyArA8kAAB5dwAAAF9Zhb3QFAsUAABYzHxNZHmhvoMVvbGljeQAApRwQxW91cmNlAAA8HyDDbGFuAACBHcIeAENhc2UBwR4AANLIHwAAlwBFY3RpdmUBOx8AAAFSIAAA8gNkaW/9A4ACAAD0yR4AAEcAZQEPAABEdGlvbvJ9GwAAJwEBiB8AAPKAAgBjdfKFcm91bmQCAGFwQcEAANkY0RxIH4kfEMtlbG9hZHN0cmVhbQAAgB0QyWF0aXZlc2VlawAAdhwQxHJyb3IAAHwbEoNuaXSCAcQAAB96ZvICbmQDgmlucoROCwAA5R0SAAD0FAAASxYAAL0XAADcGAAAxR4AABEAEABIbWFnZXRhZ3ODlh0AAINTIAAA8gNyb3CEAAH3DAAAAAABawP+QEH1ATcbVJ/vckP+/esjIsD6P2QqAtW8C9+SM7wQIlnVICZ2eAKOQymp74MC4nnlcyVN/EERDMwk+mdKBO0qXHdI/dd4ZGVnAPU5FgAANQXBABgB/AD0AADAAsQBABseJuAAAA0JOAABIQsAAAAA/RFBSMFE7U7ktZ9qO7CVG+FHlXZ123MPeTNYQFsS11JDAb2OKMEAABfVwgAAFQJkrl0l5m/LVlwRQHC0KfVNZVgpHX9MnhleK21RIUo27xDCFADz9AcAAIoQ9gIF/cA8AAIAAB3xHh3yHhWkGB3zHh0oH/MAAW7ydgQAAFIYocIAAFQQCQLRAAAgX8cAABttYmXzAIYBFx0AAAUDwwAAFgMfJX4drB8YcR9lc3QjgQLCAAAaDWxuVQHAAAAYxhljWRCyEMgQJBLSFPsCZWYBg4JunhuEsgQCwAAAE290cwsaE2FjZQwabHASBmlsYWJsZQEBywAAIHRzdgFtEAAA0oweAACjUoCBim+NFKgbAQACwwAAGJHFAAAZrmNv4hkJAACYEAAAhh4AAEQA8wEBZOLqBwAAwggAAJUYAAAGAAUCwQAAq5kcFQUVZXAAR2V0dGluZ3PhehsAAKIcAAAR8+weAACKAQIAEMpleHRlcGlzb2RlAAAJHxDKYXhlcGlzb2RlcwAACB8Qy3Bpc29kZWNvdW50AAAKH7DHZWNpc2lvbgAAdxsQxmVjb25kcwAAZCDyA293bgEAcwDTmx8AAA718gRvdW50AQBkANKaHgAA6PIDbGF5/RwwAgCDPQ0AAAAQzXBlbmxpdmVzdHJlYW0AAMAepAHGZHZhbmNlAAAcb25leHRlcGlzb2RlciB00RfygAMAYW9wEMxlcmlmeXNlc3Npb24AAJUdBInAAAAecnR2ZXJpZmljYXRpb246Hx1wdmVyaWZpY2F0aW9uBx8QyHRzZXNzaW9uAACjG/KAAgBldAwC4AAAG3V0aGVudGljYXRllB0TdWx0AxJhcyQBxmFuYWdlcgAAFnJhcHBlcgwdd9YU0BcEjMJvZwAAEW5CEhJ1dBoVEMluaXRpYWxpemUAAAoaAEVlYWRlcgEZFQAA8rEaAAD3ARDJZXRzZXNzaW9uAACiG7DCb24AAGIZEM15bmFtZXJlc3BvbnNlAADPH/IAAQBiAAGUHQAA8oZudGljYXSGABDOb25uZWN0dG9zZXJ2ZXIAAAYf8gD+AHWGCQCE9wMAAOLxFAAAihgAAFwcAAAQAPKAAgBobwQCxXdpdGNoAAASbme/Hqdlc3BvbnNlZR9pchDHYW5ndWFnZQAAaR4QxG5kZXgAAKEcEMtpc3BsYXl0aXRsZQAApx/yBHJhY2v9CAkCAIMWFQAA5GYbAABhHQAAeh0AACAfAAC/HwAAEgAAUcIAAHIecx50HnUelSAYwgAAJh3yhHJlYW2EkBDJbmlwYXlsb2FkAADTH/IFdGluZ3MBAGrhsBgAAIMZAAAI4mcdAAC5HwAAux8AAFAAEMduYWN0aXZlAAAFH/IEc2lvboQAANJEHgAAWPKAAgBzdDKAAgLDAAAfPMMAAB2nZXRveRDOb3JjZXN0cmFuc2NvZGUAAAQfIcAAAHwdaB7yA2RlYwABc4MTFQAA4WQdAABZHgAAEBKAiAErd5GbnmgxwgAAwR7IH18gEMV0cmF0ZQAA7BjygISB9gECaW/AC8D9EcgAV0H2TiLFAAAfeQCu8MpRD24il0Qsnng+6oTFBwAA6W4ZAAB7HQAAyR4AABAfAAChHwAArx8AAMEfAAB1IAAAhCAAALQgAAAAAAMAALYBAALAAGFJW3iKxWR0AAFvDQAAAQIB4AAAHo9zOzAAAAEAAPIBBwD5DAAAVhABAHMUAgCIFvUA1xgAAMYaegDVH3oAAUD86AgAANMO/wBDAZUDvgAHAHkAPQCXAKwAAQBRwwAAIhIjElwWXRZeFqUEwQAAEUweVp4dnx2gHaEdiyAmnBcmGRNDEmVocXMCGQBAATwNAADygRwAAOED8gJwdAEAcwAB6xgAABKAAQHgAAAQSW1yCD4AAcMAAB5abQFECQAAADKAg4QoRgZLYRDHbWljYm9vbAAAAhK2sAAC/bURSl6tjx5RWjVWeU4MH2gRkl/80p++JKM78Wsn2rjLF290ADowAAABAAAAAAAUABAAAADZA9YHWAr2CvcK+Ar5Ci0NxxDHE8IUgBbcF+cX6BejGS0cXRzwHDUexB9BwgAArhoqHd8egiCxxwAAbBixwwAAZQ0FAsAAABIiCxIjC21z0cYAACkWBQLAAAASPhAWjBljZoIAAQOXAAAJQ8MAABA3wwAAGW90YmdpAPLDGQAAwAUAgAH9tPVEbKzjQPxrzObQNG55ihmowwAAEIhXeaOYy8IAAA1k4AAAF5DDAAAQiT1CRkphMVbiFCoRUIfC1zK3TTBwA4stMupzUcsAAJ4dnx2gHaEdiyAFAsAAABRMFhdHG2xxGwLLAAAew2V0VcDBBAkAABLMFBOEFhOFFjgJCQsKCyEW+hjzAILzBxIAANkCHgoFAsAAAMC9EMkvHmRuI4ECTZL4H2xiaSWCwAAAGx8dNQkDCzMAwQQMxgAAGerLAAAgswFXCwAAAQADwQAADbjFAAAeYM8AACCzb3N39B8SAABQB4YAoQEVAsAAABTZGUDjCooXjRk8HmVzIRJLwAEDwQAAEgjGAAAZMRUCwgAAFnEdFGYccnR1GxsCxgAAHGdpdhUBxwAAH6UgdNIZBQLBAAAnZRx/IBlFHm52pYHAAAARwhQ9CxsCxgAAHGRkdiHHAADRGSsdBQLBAAAZRB4emx9kdBDGb3JrdXJsAAASFQUCwAAAsn0WEoAWZGcFAsAAABFNFhhJHW1vE4H9FMAAwAAAEFzBAAASIMIAABiQ0MNhbWUAAA0VFAHDdGVtAACwzRdzARIyAXQDAKYAAAtHaW5z4foIAAA/CQAAA4XIFwAA8oABAnNhbnZPAAL+QAekC0G/iQUEE0Fb+ATFAAAgd8oAAB86aXcA5/EHAADWCAAAaxAAANoYAADaHgAA4R4AAD8fAACgIAAAFQEEAACAgYKeAAAIAckAAB2dIcMAABsV+B4SAAACeUHDK3dhaAFVAQAAAAAAwAKg/kBWYVUDB/aIWlgPsVr2YJFgZwM54kjKAX/mlyQHISSo/Td7zVhICUPch44ndTIODxDVAAAgksMAABUcWp42c6rCAAAbbECffR0fDPE1JIoA5AYIAAA7CgAA+AwAAEoQAABXGwAAIAAwUcMAALQNtQ22DZMbtRwFg8AAADPpFOoUhBgaLxwhwQAATx+XHysCMPipShVidKDGZXJzaW9uAADTFKjDAAD1DAQCwAAAHWNhdGlvbnZlcnNpb26THRtwcm9iZXJlc3VsdCUdaXkARWFuZGxl4TYQAAA/EgAAAoLKGQAA9gCAwSAFgiQafe5ANgYAAf2JCQLJAAAcTGPmSk1VxwAAIHRwAPNcAQAAxgk9CzUBwAAArDoec7AQYxZrFgUCwQAAEksRqfMcc3oVhMMAABPYGDodGwLLAAAejWhwW8AChMoAAByCAEABVAEAAISkFAAAFQHDAAAWYSByPx/zAAF09gENAABBAAEAag0DAcQBBcLACAQAABhuGRJsEBoqHQEBAsAAAA1j4AAAC5Jyc+heCgAANhAAABgSAAA/EgAAdhQAAFQZAADKGQAA6xwAABkdAAAAAgAQAAEDAXcjFJZfcwERHQAAGwLBAAAU22R0xgUAAP0RyQLBAAASBzT9ug5SwgAAGR87MAAAAQAA9gACAFMBAABoAfUAOzAAAAEAAH4ABgDtF3cAkxgAAGUZAQA9HgAAnh4BAFsgAAAAAAL9ZS0OEDtwmSBVZNUI5nnZo5MUbO06U57BAAANtwmTphJEd7YyUSNrY/q498MAAB5TV4glYkRXgXqH5OAAAB6wyQAAHy1keQDz7gMAAJMZQQEBAAHgAAAdWXM7MAAAAQAAJwADAPYHAgBbEAAAexQjABsCwAAAGsJlcgUCwAAAJ08flx8wox04H28fZXMBQTswAAABAAB9AAIADhIBAMwTewChxgAANx5fAMAGNMcAAB7D4AAAEFzCAAAZ3hUBxgAAGIIgc3UfIcYAAF4gsSBrA+AAABt0YmVvBQPEAAAWIh0XKB0XKR1iaXAFAsAAABSeHhdJHW1uq/0A0AjAAAAWTcYAABjtwcQAAEIbscgAAHYdEMxkeWRvd25sb2FkZWQAACQdMoCBAsIAABt/Y3PygIKBBQLAAAAeYiAUMRlkbBEDArbBAAAX1mFjAUMeAACgyGRvd25sb2FkAAAIGhDGdHRlbXB0AADrGDIBdwKCRfyLgY1hcwHEBwAAALYAAIj9GoEAxwAAHm3CAAAbm8cAAB06hE8BAAAAMcMAAH4XfxcvHTHAAAAxHFgdmh0qGAACxQAAHrZycwEAEgAAAVAfAAAQyW9yYWdlaW5mbwAAdhvyAAEAdAE+CQAA4cweAACfIAAAMAANZWNvbW1lbmRhdGlvbgAB4AAAH61zAX8dAAAAEM1pbWFyeWltYWdldGFnAAA5HwBJYXloaXN0b3J5ASMdAAABEx8AAPKAAgBscghKjjESAAAAAEFkji4NAAAAEMhpbGVuYW1lcwAAoRsY9AAADBXQxHRpc3QAAMgXEMhocmVzaG9sZAAAcR8ARXRhdHVzAX4dAAABrB8AABDGbmFibGVkAAADH/KGZmluaXR5AwBlc3TygAIAZnL2HwFt/SWhBwHHAAAfDmThvwcAAPwHAABE8pEZAADfA1KAkALEAAAeImF5AADAASH9PPUOOa090QpSmvXvnFoZvPlLJ5NrcmU+SAaXlg0+g2G7Y2dBdJVojsVtvMEAAA0LaaIHsWbIAAAZ6XH2Sh3awgAAGJBH0b0R0MQAABipMpw605wA6+sDAADaBwAA+wcAAO4IAAAUCQAABAsAABENAAA+EAAAuxkAAAgdAAAoHQAAlCAAAAIAEAAgACUBwAAAGSQdZO8HJRYFAsAAABGPF6AoC2ZnK8AEhOYAABYaYjKWB2MFAsAAABdHHx3THm9yBcDAAMUAABORGRUfGRMqHSEUHWAgFQHDAAAWWB5ylBkQyHVkaW9vbmx5AACmIPIGcGlzb2RlgQAAAXIgAADyBW9uZXh0ggABrxgAAADyA25jZQEAdAEaCwAAgtEXAADygIGEENJwdHZpZGVvcGxheXNlc3Npb24AAHAfBQLAAABGfRxDH0QfpyATUR1zdBDGZXF1ZXN0AACtHvIFYnlpZHMBAHIAAYcfAAAYwQAAfR3yA3lpZAEAcgABHB8AAPKDYWNrAgBicxjCAAB1HPIEdWV1ZQEAcgABNB4AAMDHbGF5bGlzdAAABBrygAIAcHHygAIAb3IQx29zaXRpb24AANUUEMNkYXQAAC0N9gAAAwLCAAAcN2VwdGlyAWcBAAAAAAAD/sB95ldHRDFVC3eHa+BRGXtGqb9mW4U6bgmCvI8tP2z6GaJXrQGCdMQAAB6JwwAAGfXEAAAZVF8FhbZbxgAAGb86NIFnjsUAABiaEeSJ8mUfBnOwIgW6pG8RwgAAF5AWsSYpzmezixlId527iy1kb3YAOjAAAAEAAAAAAB4AEAAAAHQGdQZ2BncGeAbHC8gLRQ1GDUcNSA1JDUoNqhiuGdEZCBoJGg4apBpiG2obnRueG7Ub6hwrHXEdUh51H9MfMcYAAJcXmBceGQEAAsMAABe8yQAAHOhzd/iWFwAAKAAtAEYBLgABAKwAugUAAACBwAAAH8QBPQkAAIK1GgAA8gFyAQBzAIJOEAAAIcMAAF0fXx8ARmVzc2lvbgGuGAAA8sMcAACaAwBIb3dubG9hZHMBoBsAAAFxHgAAuNgAAMUX8gFlwAZAkADljBAAAAYSAAC6GgAABR8AADsfAABSIAAAYAAAAEFuATwJAAD6KRIAAN0CfAIBAPoDJwGcAXQAAQDTAPKAAgBvdjYAAIz+AKRAAUwALtA/xQAAGcBOvZ1mh8kAAB48xwAAHOkAAfAKAAAASGVzc3Rva2Vuhr8XAAABnB8AAHYIAAL9kERCdyIE8At2RC0b0sQAAB8s4AAAFL5jdAABbhQAAAGDAjd0rQF04AAAHS1pecHBAACsCBjIAACgHPIFb2x1dGUBAHAAAU0ZAAC1AsEAAKbgGRbiGXN3+gwQxWRvbmVkAABsEPKAAQFuc/YAgAL9EAkIBDzN/BzEAAAZ0GFzAAD+gAOqDwtbQn3QRS9AIQ9Ldj3MiG5Cfp6Fr08gplvZNmXRtLlhgFCE6noaHYFFeCyWACkruNeTVxjPe9jg4AAADQnJAAAgdCiEojk1R50FbeZrIKBcRDQdEk8GYQ0yMk09NSG3HS9WjrOock9vIy7AAAAD4jhhZmdpa213eHl6AO20CAAA/QoAACELAAAnDQAAiRAAAPwRAACHGAAA8hgAABcZAAChGQAAhBsAAE4cAAAUHQAAYCAAAHEAAQAAAAAQwV0AAAkAEME+AAABABDBKQAAAAAAgPAAAAAAACEAAAAAAAj+//8AEEZLMfTANCtFmV86TYAP3VTG/pQAC7f0PYcvgcNp/SiMyoAZUc4a/GI5yMfxZFurEYeWUyNKQrFLj3QDGTalzaHoAojOfwljsGAV4lURUhcKBSDmZqhCFcJqeCFB7ryFWJp7rWgHTwdUEXUvquZsUfrVOfh7EQLMDyfMvP/WIRMDzVvgAAAAACbQiZNRc0s8XV8qupFI5BN2JHBvBI5fIu8Iw8vQfVhkKfYrJnw+f+lgPg9dH3BCd12y4AAAAAHgAAAACSyWfGsPWjd1hnUb8N85BykwMTIzNDU2Nzg5Pl14eXo=","N":"B@Ki","E":"OjAAAAAAAAA=","H":"kteOkQ8="},"crateNames":{"N":"b","E":"OjAAAAAAAAA=","H":"odpdLnY="},"name":{"N":"B@Ki","E":"OjAAAAAAAAA=","H":"Z6rqbaU="},"path":{"N":"BDo","E":"OzAAAAEAAGkeKwEEAAEACAAAAAoAKQA1ABgBTwEDAFgBBwBiAQAAZwH5AGMCvQAjAwQAKQOhAMwDAQDRAwYA2QMAANsDBQDiAwcA6wMBAO8DAADyAwAA9gMAAPkDeABzBAUCegY6ALYGAwC7BgcAxAYAAMYG+ADBBwMAxgcCAMwHAADTBwMA2AcBANsHBQDiBwIA5wcEAO4HAQDyBwAA9AcBAPkHBwAECAAABgizALwIDgDMCAIA0AgEANYIEADoCAcA8wgBAPcIBAD+CAAAAAkCAAQJAQAHCQAACgkCABAJBQAbCQMAIQkAACMJAAAmCQEALgkDADMJAQA3CQAAOgkAADwJ/gA9CgQAQwoJAE4KAABQCgIAVAoAAFYKCgBiCnwA4AoBAOMKAADlCgIA6goGAPIKCgD+CgwADAsLABoLAAAcCwAAIAsBACQLAgApCwAAKwsFADMLAgA7CwAAPgsAAEALAQBFCwsAUgsCAFYLXQC1CwsAwgsAAMQLAQDHCwIAywsAAM0LCADXCwEA2gsJAOYLEgH7DAAAAA0BAAMNAAAFDQAABw0CAA0NAQARDQAAFQ0DABsNAQAfDQAAJA0AACkNAQAsDRIAQA0BAEMNAQBHDSUAbg0AAHANDAB+DSYApg0CAKoNBQCyDQYAug17AjcQAAA6EAEAPRABAEAQAQBDEAEASBAAAEoQAABOEAAAUBABAFQQAABWEAAAZxACAGsQDQB6EE8AyxAgAe0REgAGEgAAChIDABASAgAUEgAAFhICABsSAgAiEgAAKxIAAC4SAAAwEpkByxN8AEkULAB4FAAAehQuAKoUAQCtFBUAxBQFAMsUAQDOFAYA1xQAANoUAADeFAEA5BQAAOYUAADoFAEA7RQAAPIUAAD0FAAA9xQAAPkUAAD8FAAA/xQAAAcVAAAJFRMAHxUBACIV8gAXFhoAMxYXAEwWFgBkFhUAfBYOAY4XAACRFwAAlhcBAJsXAACeFwAAqBcAAKoXAACsFwAAsRcCALYXAQC6FwAAvxcdAN8XngB/GAQAhhgBAIkYFQCgGA0ArxgAALIYAAC1GAAAuxgAAL0YAADAGBYA2RgAANsYAQDjGAAA5RgAAOoYGwAHGRYAHxkTADUZKQBgGQEAYxkBAGYZAABoGQAAahkAAG0ZAQBwGQAAdBkBAHgZAQB8GQAAfhkAAIAZAACDGQoAjxk4AMkZDQDYGQUA4BkAAOcZAADpGQAA7hkAAPEZAgD2GQEA+hkBAP4ZAAABGgAABBpfAWUbEAB4GwAAehsAAIEbAQCGGwAAixsAAJEbAACXGwAAmRsBAJwbAACeG8AAYBwKAGwcCAB2HAEAfxwCAIMcAACJHAAAjBwAAI4cAACQHAMAlhwBAJkcAACdHAAAnxwFAKYcZQANHQEAEB0AABMdAAAWHQIAGx0AAB8dAAAhHSgATB0wAIAdAACCHQAAhB0AAIgdAACNHQAAkR0AAJMdwQBWHgAAWR4BAFweAABgHgAAZR4BAGgeIgCMHiAArh4AALEeBAC5HgAAvR4AAL8eOgD9HgAA/x4AAAMfLgAzHwIAOB8sAGcfAwBsHwAAbx9fANMfjQBiIAAAZCBUAA==","H":"LHFlhXs="},"entry":{"N":"KGhKLbHEc","E":"OjAAAAEAAAAAACsBEAAAAAAAAQACAAYABwAJADQATgFTAVQBVQFWAVcBYAFhAWMBZAFlAWYBYQJiAiEDIgMoA8sDzgPPA9AD2APaA+ED6gPtA/AD8QPzA/UD9wP4A3IEeQa6BsMGxQa/B8AHxQfJB8oHywfNB84HzwfQB9EH0gfaB+EH5QfmB/AH8QfzB/YH9wf4BwEIAggDCMsI1QjwCPEI8gj1CPYI/Aj9CP8IAwkGCQgJCQkNCQ4JDwkWCRcJGQkaCSAJIgkkCSUJKAkpCSoJKwksCS0JMgk2CTkJOwk7CjwKTQpPClMK3wrkCugK6QrxCv0KCwsbCx0LHgsfCyILIwsoCyoLMQsyCzYLNws4CzkLOgs9Cz8LQgtDC0QLVQu0C8ELwwvKC8wL1gvkC+UL+Qz6DP0M/wwCDQYNCg0LDQwNDw0QDRINEw0UDRkNGg0dDSENIg0jDSYNKw1CDUUNRg1tDX0NqQ2wDbENNhA4EDkQPBA/EEIQRhBJEEsQWhBbEFwQXhBgEGIQZBBlEGYQeRDKEOwRAhIHEggSCRIOEg8SGRIaEh8SIBIlEiYSLRJIFHYUdxR5FKkUwxTKFM0U2xTcFOAU4RTiFOUU5xTsFPEU+xQCFQUVHRUeFSEVFRYWFnsWjReaF50XoxenF6kXrhevF7AXtRe5F90X3heFGIgYnxizGLQYuBjXGNgY2hjpGAYZMxk0GV8ZYhllGWwZdxl7GX0ZghmOGcgZ8Bn1GfkZAxpkG3cbgBuFG40blhubG50bhRyIHIsclRybHKUcEh1KHUsdjB1jHmQeix6wHrseZR9tHw==","H":"oXrrNAwEmn8RClox6Vy3"},"desc":{"N":"HEi","E":"OzAAAAEAAF8YcwIAAAIABgA+AEYAAwBLAAIATwD/AFEBBgBbAQIAXwEHAGgBtwEhAwIAJQMDACsDAAAtAwAAMAMCADkDAQA+AwMAQwMAAEUDAQBIAwIATQOFANUDAADXAwYA4QMCAOkDAQDsAwEA8AMBAPMDAAD1AwAA9wMCAPsDegB3BAQAfQQAAJYEAACmBAAAqAQAADkFAABxBgAAdQYBAHgGAQB7BgAAfgYIAIgGAQCLBgIAjwYGAJoGAACeBgAAogYFAKkGAwCwBgAAsgYCALcGAwDABgEAwwYAAMUG+wDCBwAAxAcCAMgHAwDNBwUA1gcAANkHAgDdBwUA5AcCAOsHAADwBw4AAAgDAAkIiQCUCAUAmwgBAJ8IBgCpCAMAsQgBALQIBQC7CAEAvggBAMEIAADECAcAzggBANUIAADYCAAA3AgAAN4IAADhCAEA5AgHAO0IAQDwCAcA+QgGAAEJAAADCQAABQkKABYJAQAZCQEAHQkBACAJAAAiCQAAJAkCACgJBwAxCQMANgkBADkJAAA7CQAAPQkCAEEJAABDCQAARQn3AD8KAgBDCgUATQoAAE8KAABSCgEAVwqFAN4KAQDhCgAA4woBAOYKBADtCgEA8QoAAPQKAAD2CgEA/AoBAAALAAACCwEABgsCAAoLAQANCwAAEAsBABQLAwAbCwAAHQsHACgLAAAqCwUAMQsBADQLBgA9CwAAPwsAAEELAwBGCwMASwsDAFELAgBVCwAAVwsAAFkLAQBdCwAAXwsAAGELJQCJCwAAjgsAAJALAQCUCwAAlgseALcLAgC7CwQAwQsAAMMLAADFCwMAygsAAMwLAwDRCwEA1gsAANkLBQDjCwIA6Qv4AOMMCQDuDAEA8QwCAPUMAgD5DAIA/QwAAP8MAAACDQAABg0AAAkNAwAODQIAEg0CABYNAQAZDQEAHA0BACENAwAmDQAAKw0LADgNBAA/DQAAQQ0CAEUNAQBLDQIAUg0BAFkNAwBeDQAAYw0AAGUNAABrDQQAcQ0BAHYNBAB8DQEAgA0EAI4NAgCVDQIAmg0FAKMNAQCoDQEArQ0EALUNAQC6DX8CPBAAAD4QAQBCEAAARBAAAEYQAABJEAMAThAAAFEQAABUEAAAVhACAFoQBgBiEAAAZBACAGkQAABuEAgAeRAAAHsQBQCDEAIAihADAJMQAACVEAYAnRAEAKMQAwCoEAIArBAAAK4QAAC0EAQAuxAGAMQQAADGEAEAyRB8AEcRAABJEQAATBEDAFQRdwDNEQEA0RENAOARAgDkEQAA5hEAAOsRAQDuEQYA+hEAAPwRAgACEgAABRIAAAcSAgAOEgIAEhIAABQSAAAZEgQAHxIBACISAAAlEgEAKxIAAC0SAQAwEgoAPBIFAEMS9wA8E40AyxN9AE0UAgBRFAAAUxQAAFUUBABcFAAAXhQBAGEUAQBnFAAAaRQDAG4UAgBzFAEAdhQBAHkUAAB7FCYAoxQBAKYUBQCtFAAArxQFALgUCwDFFAUAzBQBAM8UAADTFAEA1xQAANsUAQDgFAIA5RQAAOcUAADpFAAA7BQBAPEUAQD0FAAA9hQBAPkUAAD7FAEAAhUAAAUVAAAHFQAACRUCAA0VBAATFQEAFhUCAB0VAQAhFQAAJBXyABkWCAApFgkANBYGADwWAQA/FgAAQRYAAEMWAABFFgAASRYCAE0WAQBQFgUAVxYBAFwWAABgFgIAZBYGAG0WCAB4FgMAfRYDAIIWAQCFFgEAiBb5AIMXAACGFwEAihcAAI0XAACUFwEAlxcBAJoXAACdFwEAoxcAAKcXAACpFwEArBcAAK4XAgC1FwAAtxcAALkXAAC/FwcAyBcEAM4XAgDSFwAA1BcAANgXAQDbFwQA4xcBAOYXAADsF3kAZxgCAGwYAABwGAQAdxgAAHoYAAB8GAAAfhgAAIEYAQCFGAAAiBgAAIoYAgCOGAAAkRgAAJMYAACVGAMAmxgAAJ0YAgCjGAEAphgAAKgYAQCsGAAAsxgBALYYAAC4GAAAvRgCANcYAQDaGAIA4xgAAOUYAADpGAAA7hgAAPIYAAD2GAEA+xgBAP8YAgADGQMADRkAAA8ZAgAUGQIAGBkAABoZAAAdGQAAIhkCACcZBQAuGQAAMxkCADcZBQA+GQAAQBkAAEIZAABFGQIASxkBAFEZAABTGQIAXBkAAF8ZAABiGQMAaBkAAGoZAABsGQEAcBkAAHUZAgB7GQAAfRkBAIAZAACCGQAAhRkCAIkZAgCOGQAAkBkAAJYZAQCcGQAAohkAAKQZAgCoGQAArxkMAL0ZAADEGQEAyBkAANAZAADUGQIA2RkAAOAZAADuGQAA8BkAAPIZAQD1GQAA9xkAAPkZAAD7GQAA/hkAAAAaAQADGgAABRoCAAsaAQAPGowAnhoCAKIaAQCmGgMArBoAALcaAQC8GgIAwBoBAMMaAQDGGn8ASxsFAFIbAABWGwQAXBsAAF4bAgBjGwEAahsAAG8bAABxGwAAcxsAAHcbAQB6GwAAgBsCAIUbAQCJGwIAjRsAAJEbAACVGwEAmxsAAJ0bAACfGwAApBsAAKYbBgCuGwAAsRsAALYbdgAwHAAAMhwAADQcAgA4HAAAOhwAADwcAAA+HAIARBwAAEYcAwBLHAAAURwBAFQcAwBaHAAAXxwAAGEcAABmHAAAcBwAAHMcAQB5HAEAfBwAAIMcAgCIHAEAixwBAI4cAACVHAAAmRwAAJscAACdHAAAoRwAAKMcAAClHAAApxwAAKwcAACvHAEAtxwDALwcAAC+HAAAwBwCAMUcAADIHAAAzBwAANUcAwDbHAgA5RwAAO4cAAADHQAABh0BABAdAAASHQAAGx0AAB4dAAAgHQAANR0EADsdAAA9HQEAQB0AAEIdAABEHQIASh0CAFAdAABVHQEAXR0CAGEdAgBpHQAAax0BAHQdBACEHQAAhh0CAIsdAQCTHQAAlh0DAJ8dAAChHQAAox0AAKUdAQCoHXYAJh4AACgeAAAvHgYAOh4AAFQeAABaHgAAXh4AAGMeAQBpHgAAax4AAG8eAABzHgMAex4CAH8eAACBHgAAix4AAI8eAACSHgAAlh4CAKIeBQCrHgEAsB4CALseAADAHgEAxh4AAM0eAgDUHgEA2B4BAN0eAADjHgIA6B4DAPYeAAD/HgAACh8AABEfAAAWHwAAGB8AACkfAQAsHwAALx8AADUfAAA4HwEAOx8AAEofAABVHwIAYh8BAGUfAABqHwAAbR8AAG8fAAB2HwAAix8BAJAfAgCeHwAApx8AALAfAgC5HwIAvh8AAMofAADPHwAA1R96AFMgAgCAIAAAqiABAK0gAAA=","H":"m/ZLcUs="},"function":{"N":"ALKgEo","E":"OzAAAAEAAKIDoAAAAAkANAAAAE4BGABhAgEAIQMBACgDAADLAwAAzgMCANcDIwByBAAAeAYCALUGAAC6BgAAvAYAAMMGAgC/BzwAAQgEALoIAQDLCAEAzwgAANUIAADnCAAA8AgHAPoIQQA7CgEAQgoAAE0KAwBTCgIAYQoAAN8KAQDiCgAA5AoBAOgKAwDxCgEA/QoBAAsLAQAYCwgAIgsjAFELAABVCwEAtAsBAMELAwDGCwAAygsCANYLAQDZCwAA5AsDAPcMCAACDQYACg0UACANDAA/DQAAQg0BAEUNAwBtDQIAfQ0BAKUNAACpDQEAsA0DALkNAAA2EAQAPBAEAEIQBwBLECAAeRABAMoQAADsEQEAABIQABISAwAZEhYAyhMAAEgUAQB2FAQAqRQBAKwUAADDFAEAyhQBAM0UAQDVFAgA4BQoAB0VBQAVFgMAMhYAAEsWAABjFgAAehYCAIYWAACLFwUAkhcDAJcXHwC4FwYA3RcDAH4YAACEGAIAiBgBAJ8YAQCuGA4AvhgBANcYEwAGGQEAHhkAADMZAwBfGQEAYhkKAG4ZCgB6GQgAjhkBAMgZAQDXGQAA3hkTAPMZAgD3GQwAZBsBAHYbCgCCGxQAmBsAAJsbAACdGwEAXxwAAGscAAB1HAEAeBwHAIIcDQCUHAsApRwBAAwdAAAPHQAAER0EABkdBwA7HQAASh0DAH0dBACDHQAAhR0LAJIdAAA6HgAAVR4DAFseAABdHgIAYR4GAIseAACtHgAArx4BALYeAgC6HgIAvh4AAPoeAgD+HgAAAB8CADIfAAA2HwEAZR8BAGofAQBtHwEAph8AAMofAADPHwMAYSAAAGMgAACAIAAA","H":"Ja1JrxhjhcX3IQ=="},"type":{"N":"AKj","E":"OzAAAAEAAP4e5wAEAAEACAAAAAoAKQA1ABgBTwEDAFQBAABYAQgAYgEAAGcB+QBjAr0AIwMEACkDoQDMAwEA0QMPAOIDBwDrAwEA7wMAAPIDAQD1A3wAcwRRAsYG+ADBBwMAxgcCAMoHAADMBwAA0wcDANgHAQDbBwUA4gcCAOcHBADuBwEA8gcAAPQHAQD5BwcAAwjRANYIGQDxCAMA9ggGAP4IAAAACQIABAkBAAcJAAAKCQIAEAkFABsJAwAhCQAAIwkAACYJAQAuCQMAMwkBADcJAAA6CQAAPAn+AD0K3QAcCwAAHgsDACQLAgApCwAAKwsFADMLAgA7CwAAPgsAAEALiwDNCysB+wwAAP4MAAAADQEAAw0AAAUNAAAHDQIADQ0BABENAAAVDQMAGw0CAB8NAAAkDQAAJg0AACkNAQAsDQkDNxABADoQAQA9EAEAQBABAEMQAQBIEAAAShABAE4QAABQEAEAVBAAAFYQAABaEAIAXhAAAGIQAABmEAMAaxBeAMsQNAEGEgIAChIIABQSAAAWEgIAGxICACASAAAiEgAAKxIAAC4SAAAwEkUCdxRdANcUAADaFAAA3hQCAOIUAADkFAAA5hQAAOgUAQDsFAEA8hQAAPQUAAD3FAAA+RQAAPsUAQD/FAAAAhUAAAcVAAAJFYECjhcAAJEXAACWFwEAmxcAAJ4XAACjFwAAqBcCAKwXAACxFwIAthcBALoXAAC/F+4ArxgAALIYAQC1GAAAuBgAALsYAAC9GAAAwBgWANkYAADbGAEA4xgAAOUYAADqGHcAYxkBAGYZAABoGQAAahkAAGwZAgBwGQAAdBkBAHgZAQB8GQIAgBkAAIMZWgDgGQAA5xkAAOkZAADuGQAA8RkCAPYZAQD6GQEA/hkAAAEaAAAEGnEBdxsBAHobAACBGwEAhRsBAIsbAACNGwAAkRsAAJcbAACZGwMAnhvWAHYcAQB/HAIAgxwAAIUcAACIHAEAixwBAI4cAACQHAMAlhwBAJkcAACbHAAAnRwAAJ8cbAANHQEAEB0AABMdAAAWHQIAGx0AAB8dAAAhHVsAgB0AAIIdAACEHQAAiB0AAIodAACNHQAAkR0AAJMdwQBWHgAAWR4BAFweAABgHgAAZR4BAGgeIgCMHiAArh4AALEeBAC5HgAAux4AAL0eAAC/HjoA/R4AAP8eAAADHy4AMx8CADgfLQBnHwMAbB8BAG8fXwDTH40AYiAAAGQgVAA=","H":"lLwl+7k="},"alias":{"N":"`","E":"OzAAAAEAALggAQAAALgg","H":"YhUMUyw="},"generic_inverted_index":{"N":"d","E":"OjAAAAAAAAA=","H":"0VgNNmc="}}') \ No newline at end of file diff --git a/api/search.index/type/94bc25fbb9.js b/api/search.index/type/94bc25fbb9.js new file mode 100644 index 00000000..1525690e --- /dev/null +++ b/api/search.index/type/94bc25fbb9.js @@ -0,0 +1 @@ +rd_("EOg[\"AAAAAAAAAAABBB8AAAABZR0AAA==\",\"AAdNAwAAewYAAEoKAAA6HQAAdCAAAJIgAACzIAAAOjAAAAEAAAAAAAgAEAAAAAYIhwvvHvEe8x4oH1wfXCCGIDowAAABAAAAAAAcABAAAAC7BsIGBwjWCNsIiAuJCxwVXxbnF6QYpxnCGdsZYRtrG2QcZRwsHTMdcR2dHZoeyB5BH3wfmB+pH8UfOjAAAAEAAAAAADAAEAAAAE4DdwR4BHkEegR7BHwEfQS2BrcGCAjQCNEIPQpRClIK+wqKC8ALrA24DSEW6BcwGUMZWxnMGc4Z3BlpG2IcYxxxHC4eUh6cHuwe7R4FHxAfch97H58foB/CH8MfyB/JH44gOzAAAAEAAEcBLQCWBgAAoAYAAL0GAQDCCAAA2QgAAEgKAACGCwAAmA0AAEkRAAA7E4oAdhYBACUZAAA/GQAAyhkBAM0ZAADTGQAADxqJAFsbAABoGwAAbhsAAE0cAABhHAAA7xwBAPccAgD8HAAAMh0AAG0dAAB6HQAAQh4AAEQeAQDgHgAA7h4AAAcfAAAlHwAATB8AAGQfAACDHwAAmx8AALMfAAC2HwAAYCAAAGYgAAByIAEAfyAAAJ4gAAA6MAAAAQAAAAAAOQAQAAAAoQaiBqMGpAamBq4GvwbABsEGkwiVCMMIxAjFCMYIyAheCu8Kew3CELYU0RT1GE0ZThmuGaQaZhtsG10cYBxnHMYc0xwEHSUdWh1bHZwdQR5KHk0eqB7XHhMfIh9FH1Ifeh+EH40fxx/NH1YgWCBnIH0giCA7MAAAAQAALQE6AGcBAACYBgAAmgYAAK8GAwC0BgAASQoAAPoKAACtDQAArw0AAOQRAADmEQAA6BEAALcUAwC8FAAAGxUAAEQWAADqFwAAoRgAAPoYAAAfGQAAphkAAL4ZAADBGQAA2BkAAFQbAABnGwAAdBsAAKMbAACxGwAAtht2AG0cAADHHAAA/xwAAAgdAABmHQAAbh0AAKYdAACoHXYAOx4AAD0eAQBJHgAAiR4AANseAADoHgAA+B4AAB0fAAAfHwAARh8AAF8fAACWHwAAmR8AAJ0fAADAHwAAWSAAAH4gAACKIAEAlCAAAKYgAAA6MAAAAQAAAAAAKAAQAAAAdAZ1BnYGdxDjEecRohTQFOsXDhpVG2EdYx1nHWgdaR1qHWsdbx15HTYeOR5DHkYeTB5RHpAenx7RHuke6x70HvkeJh9HH2MfWyCdIKAgoiCvIDowAAABAAAAAAAXABAAAAB2BKUGlAjHCJULIxWdGCAZYhtjG04c+hxHHVEdUx1IHtIe3h7hHiQfOh9LH1Mfnh86MAAAAQAAAAAADwAQAAAAmQaoBrMGrg3DEOURuxQaFUobVhtLHvIehh+OH3wgqCA6MAAAAQAAAAAADAAQAAAA3Qo/FtEZSRu1G2IdbB2eHuoeGx99H4UfuB8CcB4AAJsgAAAE7wgAANoeAACJHwAAaSAAAAOgHgAAQh8AALUgAAAAAAABPhYAAA==\"]Lg[\"AAAAAAAAAAAAAAAAAAHEEAAAA6QUAACmFAAAuxkAAAbFEAAAxhAAAKUUAACnFAAAvBkAAL0ZAAADSQ0AAIIXAACDFwAAAaoYAAAAAAAAAAGFIAAAAAHoHAAA\",\"AAAAAAAAAAAAAAAAAAAAAAAAOjAAAAEAAAAAAAkAEAAAANQXZRioGNkZqRqkGzAcpR0RH3Yf\"]Fo[\"AAAAAAAAAAAAAAAAAAHEEAAAA6QUAACmFAAAuxkAAAbFEAAAxhAAAKUUAACnFAAAvBkAAL0ZAAABSQ0AAAGqGAAAAAAAAAABhSAAAA==\",\"\"]Gc[\"AAAAAAAAAAAAAAAAAAAAAAAAOjAAAAEAAAAAAAkAEAAAANQXZRioGNkZqRqkGzAcpR0RH3Yf\",\"AAABdwYAAAAAAAAAAAAAAAAAAAAAAAE2AAAA\"]Bk[\"\",\"AAAAAAAAAAAAADswAAABAAD1AAEAWAD1AA==\"]Da[\"AAAAAAAAAAAAAnYEAACVCwAA\",\"AAAAAAAAAAAAAAAAAAAAAAAAAeYIAAA=\",1]AAg[\"AAAAAALtHgAADR8AAAONGQAAoRsAAPwcAAACsRwAACIfAAA7MAAAAQAA9wAEAMsQegDiFwAAxhp6ADseAAACBB8AAFsfAAAAOzAAAAEAABAABwBJGQAABBoDAJ0aAwBlHQAANx4BAJUeAwB8IAAAAp0eAACFHwAAAl8dAABwHgAAAY4eAAAAAAFYHQAAAAAAAccZAAAAAAGFIAAA\",\"AAABxh4AAAABnQgAAAGnCAAAAAGvEAAAAAAAAAAAAAAAAgENAADyGQAAAZUZAAA=\"]Do[\"\",\"AAAAAAAAAAAAAAAAAAAAAAAAOjAAAAEAAAAAAAkAEAAAANQXZRioGNkZqRqkGzAcpR0RH3Yf\"]Hc[\"AAAAAAA7MAAAAQAAiQABAA8aiQAAAeIXAAAAAAAAAAAAAAAAAAABxxkAAA==\",\"AAACsBAAAMYeAAAAAAAAAANKDQAAkhkAAJMZAAABHx4AAAAAAAAAAAAAAckLAAA=\"]Bk[\"\",\"AAAAAAAAAAAAADswAAABAAD1AAEAaAH1AA==\"]BFa[\"AAAAAAIqAwAASQMAADowAAABAAAAAAAJABAAAABDA4YQmhmbGb8ZzRloG/cc+Bz5HAQtAwAARQMAANceAAChIAAABUcDAAAbFQAAphkAAMMZAACZHwAABzUDAAA2AwAAdxAAAOMRAADrFwAAXBsAADkeAAA6MAAAAQAAAAAACAAQAAAANwM5A6gUIxWaGCAZmh3FHkgfBLIbAAAqHQAA8h4AAJcfAAAEPxYAAEAfAAB9HwAAgiAAAAM9AwAArhoAALIaAAAD7wgAAN8eAABpIAAAAAABWB0AAAAAAAHHGQAAAAABhSAAAA==\",\"AAAAAaANAAABVB0AAALNCAAA/hgAAAShDQAAvxoAANQcAABxHgAAAR4DAAAAAAAAAAAAAAAAOjAAAAEAAAAAAEgAEAAAACUDJwNzBHUEzgjeCOUIRApFCkcK4QoDCwcLUAvFC9ILdQ2kDTASyBPJE8sTYRRiFKsUsRQJFQoVCxUlFi4WLxYxFnkW0BfkF+YXkhibGP0Y/xgBGRgZGhlBGUIZUxlUGaIZoxq8Gr0avhrAGsEanxugG64bLRxIHKMc1RzWHNccIx1VHVYdox3BHsQe2R4KHzsf\",1]AIa[\"AAAAAAAAAAAAATkDAAA=\",\"AAAAAAA7MAAAAQAADQAEAGYYAwATGQMAxBwBAHoeAwACjwsAAJELAAA7MAAAAQAANgAPAKkGAABdDQMAUBQDAEAWAwACGQEAIRkDAKUaAwBwGwMAUxwDALYcAwC7HAMANB0DAJ4dAwByHgMArx8DADswAAABAAAUAAYATBQDAAkZAwBAGQAA4h4DAFQfAwCPHwMAOzAAAAEAABgABwD/CgMAghADAF0bAwAzHAMAvxwDACUeAwDFHgAAOzAAAAEAABsABwAEGgMAnRoDADkcAwBDHQMAfh4DAJUeAwDMHgMAAAAABKkgAACqIAAAqyAAAK0gAAAGxRAAAMYQAAClFAAApxQAALwZAAC9GQAAAoIXAACDFwAA\",1]Dg[\"AAAAAAAAAUQJAAAAAAJ2BAAAlQsAAA==\",\"AAAAAAAAAAAAAAAAAAAAAAAAARcLAAA=\"]AGg[\"\",\"AAAAAAA7MAAAAQAADQAEAGYYAwATGQMAxBwBAHoeAwACjwsAAJELAAA7MAAAAQAANgAPAKkGAABdDQMAUBQDAEAWAwACGQEAIRkDAKUaAwBwGwMAUxwDALYcAwC7HAMANB0DAJ4dAwByHgMArx8DADswAAABAAAUAAYATBQDAAkZAwBAGQAA4h4DAFQfAwCPHwMAOzAAAAEAABcABgD/CgMAghADAF0bAwAzHAMAvxwDACUeAwA7MAAAAQAAGwAHAAQaAwCdGgMAORwDAEMdAwB+HgMAlR4DAMweAwAAAAAEqSAAAKogAACrIAAArSAAAAbFEAAAxhAAAKUUAACnFAAAvBkAAL0ZAAACghcAAIMXAAA=\"]Fc[\"AAAAAANMHAAAXR0AAF4dAAAAAAAAAAAAAAHEEAAAA6QUAACmFAAAuxkAAAbFEAAAxhAAAKUUAACnFAAAvBkAAL0ZAAA=\",\"\"]Ag[\"AAAAAAAAAAFvGAAA\",\"\"]BCo[\"AAAAAAAAAYoXAAADZwEAAOgRAACdHwAABOcRAACiFAAA0R4AACYfAAAFCBkAAFEdAADSHgAA3h4AACQfAAACShsAAFMbAAABSRsAAAAAAUIfAAA=\",\"AAACsBsAALMcAAAABJILAACzEAAAaBQAAFcgAAACjRkAAKEbAAA6MAAAAQAAAAAADAAQAAAAfw1qGGsYsRz2HAQdLB4tHkceEx9/H4AfrB86MAAAAQAAAAAACgAQAAAA0Bz+HNse9R4SH14flR+KIJcgnCCuIDowAAABAAAAAAAIABAAAABKDZIZkxmtGTEcUh1qHsoejSA6MAAAAQAAAAAADAAQAAAAqBSaGEoZtRq2Gmwcmh0fHuYerR/EH3ogtCADoR4AAD0fAABrIAAABEAfAABsIAAAmCAAAJ8gAAACqxoAAHAeAAAAA6QUAACmFAAAqhoAAAGRGQAAAVgdAAAB/RwAADowAAABAAAAAAAeABAAAACjCKQIpQimCKgIqQiqCKsIPQk+CeMKkwtjDWwNqw1tEHYQ6RHqEfMR9BG+FHgWKRksGVAZrRq0HAMdbx6rHg==\"]Ag[\"\",\"AAAC0BkAAKUbAAA=\"]Mc[\"AAABTxwAAAAAAAK2FAAAsRwAAAW3FAAAuBQAALkUAAC6FAAAvBQAAAHbHAAAAAG7FAAA\",\"AAACsBsAAE8cAAAFDgsAAA8LAAAQCwAAFAsAAIQWAAABnQgAAAAAAdseAAAAAAAAAAAAAAAE3hQAAN8UAAAWHQAAFx0AADowAAABAAAAAAAHABAAAAANCxELEgsTCxULFgstGS4d\"]BHg[\"AAA6MAAAAQAAAAAABwAQAAAAJBYuHF4c0hwKHSQefh+rHwXtGAAAKR0AADEdAADCHgAApSAAAAd1GwAAEB8AAMIfAADDHwAAyB8AANQfAACOIAAABVsbAABCHAAAWxwAAPAcAADgHgAAOjAAAAEAAAAAAAcAEAAAAK4G7wrrGE0ZsxtdHEAeViAGrwYAALAGAACyBgAArQ0AAK8NAAAfHwAAA1IWAAB5HQAAnSAAAAW1GgAAthoAAEsfAACeHwAAxB8AAAOuDQAAShsAAIYfAAABhCAAAAAC2h4AALggAAA7MAAAAQAABgADANocAACpIAQAtiAAAAG/HwAAAbwfAAA=\",\"AAAFbhwAANIcAAAKHQAAeB8AAKsfAAA6MAAAAQAAAAAACQAQAAAAiQ2NDY4NlA3tGBwZoBwpHckeXyA6MAAAAQAAAAAACAAQAAAATg1RDVINVw2KDRsZzxzUH6MgAlscAAB1IAAAA7MbAACcHQAAjR8AAAAAAAAAAAAAAAA6MAAAAQAAAAAABwAQAAAAmRuSHGAetB61HmkfcSCRIDowAAABAAAAAAAXABAAAACqBqsGCQtPDVANVQ1WDYANgQ2FDYYNhw2IDYsNjA2PDZENkw2VDZcNnQ2eDWwW0xc=\"]AKg[\"AAAAAWsWAAACUQoAAFIKAAAAA2YbAAAgHwAALR8AAAf1CgAA+hgAAGcbAABmHQAAbh0AAL0fAADAHwAABmEdAABjHQAAbx0AAOkeAADrHgAAWyAAAAFsHAAABj0cAAA+HAAAPxwAAEAcAAChHgAAbSAAAAU/FgAAYh0AAOoeAACZIAAAmiAAAASCHgAAgx4AAIQeAACFHgAAAbggAAA6MAAAAQAAAAAACQAQAAAAqhraHKAeqSCqIKsgrCCtILUgtiABvx8AAAG8HwAAAf0cAAAAAAHHGQAA\",\"AAGJIAAAAAAAAAMtHwAALh8AAI8gAAAAAVwbAAAAAAAAAAAAAAUREgAA9hkAAIEcAAAYHQAArh4AADowAAABAAAAAAAQABAAAACeCJ8IoAihCPkIYArsCkQNnA1sEEYRnxS/FMEUYBZhFmIW\"]BAo[\"AAAFBggAAAQLAACHFgAAbhwAAHgfAAABoA0AAALqHAAAUh4AAAAErhkAAKQaAABNHgAAqB4AAAehGAAAphkAAG0cAAAIHQAAPh4AAIkeAACUIAAAOjAAAAEAAAAAABIAEAAAAA4aOR7iHuMe5B7lHkcfVB9VH1YfVx9jH48fkB+RH5IfoCCiIK8gBCAZAABiGwAA4R4AAFMfAAAD8h4AAI4fAACoIAAABT8WAADRGQAASRsAALUbAAB9HwAAAZsgAAAAAUIfAAAAAAAAAAAAAAGFIAAA\",\"AAAChxYAAF4cAAACaxYAAKUgAAAAAZsZAAAFoQ0AAJQZAABtHgAAcR4AAMMeAAACDh8AAA8fAAAB+BgAAAAAAk4eAAA/HwAAAasaAAAAAaoaAAABkRkAAAFYHQAAApobAAC9HgAAOjAAAAEAAAAAACIAEAAAADsARABHAFwNOhNnFBoWGxYcFh0WHhYfFiAWIhY6FlkWWhZ+F4AXgRewGbEZsxm0GbUZthm3GbgZphunG2YcLx1CHdQe1R4=\"]FEc[\"AAAAADswAAABAAAMAW0AdwQEAAgIAADQCAEAPQoAAIoLAAC4DQAAIRYAAIgWAACLFgEAjxYAAJEWAACTFgEAlxYAAJkWAACbFgEAnxYBAKMWAAClFgEAqBYAAKoWAACtFgAArxYBALMWAQC2FgAAuBYAALoWAAC8FgAAvhYAAMEWAQDEFgAAxhYAAMkWAADLFgEAzhYAANEWAQDUFgAA1xYBANsWAQDfFgAA4RYBAOQWAADnFgAA6RYAAOsWAQDvFgEA8hYAAPQWAAD3FgAA+RYBAP0WAAD/FgAAARcBAAUXAQAJFwEADBcAAA4XAAAQFwAAExcBABYXAAAYFwAAGhcAABwXAAAfFwEAIhcAACQXAAAmFwAAKBcAACsXAQAuFwAAMRcBADUXAAA3FwAAORcBAD0XAQBAFwAAQxcAAEUXAABHFwEASxcAAE0XAQBRFwEAVBcAAFYXAABYFwAAWhcAAFwXAABfFwAAYRcAAGMXAQBnFwAAaRcAAGsXAQBuFwAAcRcBAHUXAQB4FwAAexcAAH0XAADoFwAAMBkAAEMZAADcGQAAcRwAAK4cAAAuHgAAnB4AAAUfAACfHwEA1R96ADswAAABAAALA2gAQgMAAGIKegBEEvUAPBOJAHYWAACJFgEAjRYBAJAWAACSFgAAlRYBAJgWAACaFgAAnRYBAKEWAQCkFgAApxYAAKkWAACrFgEArhYAALEWAQC1FgAAtxYAALkWAAC7FgAAvRYAAL8WAQDDFgAAxRYAAMcWAQDKFgAAzRYAAM8WAQDTFgAA1RYBANkWAQDdFgEA4BYAAOMWAADlFgEA6BYAAOoWAADtFgEA8RYAAPMWAAD1FgEA+BYAAPsWAQD+FgAAABcAAAMXAQAHFwEACxcAAA0XAAAPFwAAERcBABUXAAAXFwAAGRcAABsXAAAdFwEAIRcAACMXAAAlFwAAJxcAACkXAQAtFwAALxcBADMXAQA2FwAAOBcAADsXAQA/FwAAQRcBAEQXAABGFwAASRcBAEwXAABPFwEAUxcAAFUXAABXFwAAWRcAAFsXAABdFwEAYBcAAGIXAABlFwEAaBcAAGoXAABtFwAAbxcBAHMXAQB3FwAAeRcBAHwXAADKGQEADxqJAFsbAAD8HAAAbR0AAHodAABEHgEAZB8AAMwfAAA7MAAAAQAA0AAQAGQCuQCXBgAAoQYDAKYGAAC/BgIAkwgAAJUIAADDCAMAyAgAAEQJAADvCgAA8woAAJkNAAC6EAAA6xgAAGccAAA7MAAAAQAA/QEYAGcBAACYBgAAmgYAAK8GAwC0BgAA9QoAAJALAADhCwAArQ0AAK8NAACvEAAAyxB6AOQRAADmEQAA6BEAALcUAwC8FAAA+hgAAMYaegBnGwAAtht2AKgddgA7HgAAmR8AADowAAABAAAAAAAPABAAAAB0BnUGdgY3HGEdYx1nHWgdaR1qHWsdbx1DHuke6x4mHwOlBgAAlAgAAMcIAAAFmQYAALMGAACuDQAA5REAALsUAAADYh0AAGwdAADqHgAAAAAAAAKCFwAAgxcAAA==\",\"AAAAAescAAAAOzAAAAEAAPYAAgDTCwAARBL1AAABrxAAAAE3HAAA\"]DBo[\"AAAAOjAAAAEAAAAAABEAEAAAAPAKTBaPGMAZxhlcHOkc8xz0HEgdWR18HTweYR+hH8Efyx+QIAV1GwAAPB0AACMeAAALHwAAsSAAADowAAABAAAAAAAPABAAAACfBl8KhgvTC+IMOxOcGCUZJhkuGS8ZTxnTGWEcpxwyHTowAAABAAAAAAAJABAAAABeCrcNFxmoHGQdcB0hHiIfPB96HzswAAABAABCAB8AHgMAAEkKAABhCwAAjgsAAF0NAwCHEAAAUBQDAEAWBADqFwAAbxgAAAIZAQANGQAAIRkDAKUaAwBwGwMAsRsAADIcAABTHAMAqRwAALYcAwDHHAAANB0DAJ4dBACmHQAAOx4AAHIeAwCbHgAARh8AAHMfAABlIAAApiAAADowAAABAAAAAAAoABAAAAAgA0oNYQ1MFE0UThRPFNAUKBYOGQ8ZEBkRGRIZQBmSGZMZnBlcGzEcnx7KHuIe4x7kHuUeBB8MH1QfVR9WH1cfWR9jH3QfdR+PH5AfkR+SH1sgOjAAAAEAAAAAAC4AEAAAANII/woACwELAguCEIMQhBCFEKgUrhQjFZoYnRggGUoZtRq2Gl0bXhtfG2AbYxszHDQcNRw2HE4cvxzAHMEcwhz1HEcdYB2aHR8eJR4mHiceKB53HngeeR7SHkgfnh87MAAAAQAALAATAKgGAADDEAAASRkAAAQaAwCdGgMAVhsAALIbAAA5HAcAKh0AAEMdAwBlHQAANx4BAH4eAwCVHgMAzB4DAD0fAACXHwAAWiAAAHwgAAA6MAAAAQAAAAAACgAQAAAA3Qo/FkcbVx2dHp4eQB99H4UfjB+CIAeuGgAAshoAAF8dAACCHgAAgx4AAIQeAACFHgAAOjAAAAEAAAAAAAkAEAAAALMI7wjdGY4e3x4VHxYfFx8YH2kgOzAAAAEAAAUAAgCpIAQAtiAAAAABWB0AAAAAAAHHGQAAAAABhSAAAA==\",\"AAACsxoAALQaAAABaRQAAAWvCAAAQwkAAEwKAADtDAAA8AwAAAI7FgAALhkAAAEEHQAAAeIXAAAAAAFlHQAA\"]Ck[\"AAAAApkIAABEGQAA\",\"AAAAAAAAAAAAAdIIAAAAAAAAAAAAAAGnBgAA\"]A@k[\"AAABIh4AAAF3HwAAASIdAAACmx8AAHIgAAAAAAACUx0AAB8eAAAEQx0AAEQdAABFHQAARh0AAAHyHAAA\",\"AAFBHQAAAAF3HwAAAXIUAAAAAusYAACcHQAAAfUKAAAAAAAAAAAAAAA6MAAAAQAAAAAACwAQAAAAIQu9GG0ZgRuEHVoesR6yHjMfNR9iIHAgOjAAAAEAAAAAABEAEAAAAGMC6Qs8DUcRZhStFEgWSRZKFoMYqhyrHKwc7BztHO4cCB9kIA==\"]AAc[\"AAAAAUQZAAABVyAAAAABtw0AAAAEdxAAAOMRAADrFwAAUR4AAAABKh0AAAABrhoAAAAAAAFJDQAAAaoYAAA=\",\"AAAAAyMDAADsGAAARBkAAASBEAAARxYAAF4ZAAAoHQAAAoYQAACaGQAAAmocAACNHwAAAAAAAAGCIAAAAa4aAAAAAAAABTsQAAAQHQAAOB8AAGgfAABvHwAAOjAAAAEAAAAAAA4AEAAAAGAC2Ai9FM8UPRZRGVIZqxkMGkYbqBtvHHAcmR1PHg==\"]Bi[\"AAAAAAJKAwAAbRQAAAABihcAAAGwGgAA\",\"\",1]Dg[\"AAAAAZAYAAACsxAAAKcYAAABnBgAAAFeCgAAA+IXAACcGgAAoh0AAAAB0ggAAA==\",\"\"]Nk[\"AAAAAAAAA7UNAADZFwAAixkAAAAAA6UGAACUCAAAxwgAAAWZBgAAswYAAK4NAADlEQAAuxQAAANiHQAAbB0AAOoeAAABJx0AAAAABsUQAADGEAAApRQAAKcUAAC8GQAAvRkAAANJDQAAghcAAIMXAAABqhgAAAAAAAAAATEZAAAAAegcAAA=\",\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMRkAAAAB6BwAAA==\"]Dk[\"AAAAASMDAAAAARUAAAABwQIAAAAAAAAAAAAAAAAAAmACAADYCAAA\",\"AAAAAAAAAbcNAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAFJDQAAAaoYAAA=\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAVwNAAA=\",\"\"]Gk[\"AAAAAQsIAAAAAVcRAAABZQIAAAAAAAAAAAAAAAAABDgAAACABgAA1BEAANwcAAA=\",\"AAAAAQsIAAABJxUAAAAAAAAAAAAAAd0ZAAAAAAAAAsYGAADIBgAA\"]CGo[\"AAACXB8AAFwgAAABwh4AAAcIGgAACRoAAGMcAACkHAAAPB0AABofAAB7HwAAAmweAACBIAAABU4ZAACxHAAAJR0AAEAeAACIIAAABfoKAADkEQAA5hEAALAaAACIHwAABEAZAABMHgAABB8AAGAfAAAABOURAABlHQAAhh8AAI4fAAABPxYAAAAAAdocAAABvx8AAAG8HwAA\",\"AAWLCwAA6RcAAHUdAAAZHwAALx8AAAMECwAAdRgAAKEZAAA6MAAAAQAAAAAALwAQAAAAvQhnDYgQiRCOEJIQnBCgEKEQWxRcFGAUYxRkFGUUKRYqFisWLBYtFm4Ydhh4GHkYohifGboauxpBHEMcwxzNHCsdLR0pHioehh7wHicfYR95H4Ifmh+hH8EfUiB3ILIgOzAAAAEAAIEABgDME3sACBoBAK4cAADqHAAAyx4AALEgAAA7MAAAAQAAMgAKAAoAKQCfBgAATxkAALkaAABCHAAApxwAANweAACqHwAAzB8AAIMgAAAGRAkAALoQAACeGQAAoBkAAHAdAACMHgAAAW8YAAACBB8AAGAfAAABlR0AAAEqHQAAAAEAHQAAAAAAAAA6MAAAAQAAAAAAZAAQAAAAvAi+CL8IwAjBCPgIjAuNC9oL2wvcC90L3gvfC+AL6As9DT4NZA1lDWYNpw2oDTUQjBCPEJAQkRCTEJQQlRCWEJcQmRCaEJsQnRD1EfoR+xH/EVgUWRRaFF0UXhRfFBkWMxY0FjUWNhY5FlsW0RfSF2wYbRh3GIIYihhcGV0ZiBmYGaUZahsvHEscUByyHMgcyRzKHMsczBzOHAYdCR09HT4dcx17HSseoh6jHqQepR6mHqcevx7AHvceAx8JHyMfKR8qHysfYh/GHw==\"]EJk[\"\",\"AAdNAwAAewYAAEoKAAA6HQAAdCAAAJIgAACzIAAAOjAAAAEAAAAAAAgAEAAAAAYIhwvvHvEe8x4oH1wfXCCGIDowAAABAAAAAAAbABAAAAC7BsIGBwjWCNsIiAuJCxwVXxbnF6QYpxnCGdsZaxtkHGUcLB0zHXEdnR2aHsgeQR98H5gfqR/FHzowAAABAAAAAAAvABAAAABOA3cEeAR5BHoEewR8BH0Etga3BggI0AjRCD0KUQpSCvsKigvAC6wNuA0hFugXMBlDGVsZzBnOGdwZaRtiHGMccRwuHlIenB7sHu0eBR8QH3sfnx+gH8Ifwx/IH8kfjiA7MAAAAQAARwEtAJYGAACgBgAAvQYBAMIIAADZCAAASAoAAIYLAACYDQAASREAADsTigB2FgEAJRkAAD8ZAADKGQEAzRkAANMZAAAPGokAWxsAAGgbAABuGwAATRwAAGEcAADvHAEA9xwCAPwcAAAyHQAAbR0AAHodAABCHgAARB4BAOAeAADuHgAABx8AACUfAABMHwAAZB8AAIMfAACbHwAAsx8AALYfAABgIAAAZiAAAHIgAQB/IAAAniAAADowAAABAAAAAAA1ABAAAAChBqIGowakBqYGrga/BsAGwQaTCJUIwwjECMUIxgjICF4K7wp7DcIQthTRFPUYTRlOGa4ZpBpmG2wbXRxgHGccxhzTHCUdWh1bHUEeSh5NHqge1x4iH0UfUh96H4Qfxx/NH1YgWCBnIH0giCA7MAAAAQAAKwE5AGcBAACYBgAAmgYAAK8GAwC0BgAASQoAAPoKAACtDQAArw0AAOQRAADmEQAA6BEAALcUAwC8FAAAGxUAAEQWAADqFwAAoRgAAPoYAAAfGQAAphkAAL4ZAADBGQAA2BkAAFQbAABnGwAAdBsAAKMbAACxGwAAtht2AG0cAADHHAAA/xwAAAgdAABmHQAAbh0AAKYdAACoHXYAOx4AAD0eAQBJHgAAiR4AAOgeAAD4HgAAHR8AAB8fAABGHwAAXx8AAJYfAACZHwAAnR8AAMAfAABZIAAAfiAAAIsgAACUIAAApiAAADowAAABAAAAAAAoABAAAAB0BnUGdgZ3EOMR5xGiFNAU6xcOGlUbYR1jHWcdaB1pHWodax1vHXkdNh45HkMeRh5MHlEekB6fHtEe6R7rHvQe+R4mH0cfYx9bIJ0goCCiIK8gOjAAAAEAAAAAABYAEAAAAHYEpQaUCMcIlQsjFZ0YIBliG2MbThz6HFEdUx1IHtIe3h7hHiQfOh9LH1Mfnh86MAAAAQAAAAAADwAQAAAAmQaoBrMGrg3DEOURuxQaFUobVhtLHvIehh+OH3wgqCA6MAAAAQAAAAAADAAQAAAA3Qo/FtEZSRu1G2IdbB2eHuoeGx99H4UfuB8BmyAAAAPvCAAA2h4AAIkfAAADoB4AAEIfAAC1IAAA\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAjMNAAA3EgAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAjgNAAA8EgAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAo8QAACxEAAA\",\"\"]Fg[\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMRkAAAAB6BwAAA==\",\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMRkAAAAB6BwAAA==\"]Ac[\"\",\"AAAAAZkIAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAApwIAAC4CAAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAroLAABbFgAA\",\"\"]Bg[\"AAAAAqIYAACMGQAA\",\"AAAAAAAAAVwdAAA=\"]Ag[\"\",\"AAAAAAAB0x8AAA==\"]Ao[\"\",\"AAAAOzAAAAEAAHYAAQD7A3YA\"]Bg[\"\",\"AAAAAAAAADswAAABAAB2AAEAtht2AA==\"]Fg[\"AAAAAXgIAAAAAbQRAAAB9AIAAAAAAAABnR4AAAAAAAAAAANRAAAAkQYAAAwVAAA=\",\"AAAAAXgIAAAB1BUAAAAAAAAB5h4AAA==\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAn8QAABFHAAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAm4GAACQEAAA\",\"\"]Eg[\"AAAAAAFJAwAAAAHXHgAAAUcDAAACNQMAADkeAAAAAfIeAAA=\",\"AAAAAAABzQgAAAOhDQAAvxoAAHEeAAA=\"]Do[\"AAAAAAAAAAGZHwAAAjUDAAA2AwAAAzcDAAA5AwAAxR4AAAAAAT0DAAA=\",\"AAAAAAAAAdQcAAA=\"]Eg[\"AAAAA1QIAACJEAAAjhAAAAABGgAAAAHMAgAA\",\"AAAAAVQIAAAAAAAAAAAAAAAAAAAAAALJCAAAyggAAA==\"]Ig[\"AAAAAY4IAAAAAAOPCwAAkQsAAAQZAAABkAsAAAAAAAAAAcQQAAADpBQAAKYUAAC7GQAABsUQAADGEAAApRQAAKcUAAC8GQAAvRkAAAAAAsoDAADjCgAA\",\"AAAAAY4IAAABSgMAAAABihcAAA==\"]Ao[\"\",\"AAAAAAADshAAAMgQAADSFAAA\"]Cc[\"AAAAAAAAAAAAAAAAAAAAAAAAA1oLAACvCwAAthkAAA==\",\"\"]CEk[\"AAAAAAA6MAAAAQAAAAAAHgAQAAAApRj0GFccaRxOHZsdNR7cHt0eBx8UHx4fPh9MH00fqh+zH7Qfth+3H7kfuh9QIGYgbyBzIHUgdiB/IIMgniA6MAAAAQAAAAAALgAQAAAAew3RFPUYlBmgGa4ZpBrCGmwbohtqHMYc0xz2HFsdLB5HHkoeTR5tHogejB6oHsMe1h4GHyAfIR8tHy4fRR9SH1gfhB+LH6wfux/HH80fUSBWIFggZyB9IIggjyCnIDowAAABAAAAAAAxABAAAABRFpUYNxlUG6MbaBxtHNAc2Bz+HP8cUB1yHZQdPR4+Hj8eSR5QHokekh7nHvUe+B4OHw8fHR8fH08fXR9eH18fiB+VH5YfnB+dH70fwB9ZIG4geSB+IIsgkyCUIJUglyCcIK4gOjAAAAEAAAAAAB4AEAAAAFIW+BitGa8ZDhpVG9scBR1SHTQeNh5GHkweUR6QHtAe0R70HvkeRx9gH4ofXSCHII0gnSCgIKIgryCwILcgOjAAAAEAAAAAABcAEAAAAEISUxYIGWIbbBz6HFEdUx2VHUgemR7eHuYeJB86H0sfrR/EH3ggeiB7IIwgliC0IDowAAABAAAAAAAOABAAAAAaFQoaUxtYHNkcSx6hHoYfkx+UH74fayBtIKQgqCA6MAAAAQAAAAAAFAAQAAAAUBYZGdEZ2hlSG7Ub8hxOHqoeGx8cHz8fWh+HH7gfbCCEIJggmSCaIJ8gA6saAAAAHQAAmyAAAAPaHgAAiR8AALggAAAGqhoAANocAACgHgAAQh8AAFEfAAC1IAAABZEZAADxHAAATx0AAFAfAAC/HwAAAbwfAAAB/RwAAA==\",\"\"]Ag[\"\",\"AAAAAAABjRkAAA==\"]Cc[\"AAAAAAAAAAHoHgAAAAAAAZ4eAAA=\",\"AAAAAAAB0x4AAA==\"]AFg[\"AAAAAAAAADowAAABAAAAAAB3ABAAAAAlFSYVKRUrFS0VLxUxFTMVNBU2FTgVOhU9FT8VQRVDFUUVRhVIFUsVTBVPFVAVUxVVFVcVWRVaFVwVXhVgFWMVZRVnFWgVahVtFW8VcBVyFXUVdhV4FXsVfRV+FYEVgxWEFYYViRWKFYwVjhWRFZMVlRWXFZgVmxWcFZ8VoBWiFaUVpxWoFaoVrRWuFbEVsxW0FbcVuRW6Fb0VvhXAFcMVxBXHFcgVyhXMFc8V0RXSFdUV1xXYFdoV3RXfFeAV4xXlFecV6RXrFe0V7hXwFfMV9BX2FfgV+hX9Ff8VARYDFgUWBhYIFgoWDBYOFhAWExY=\",\"\"]Ck[\"\",\"AAABxh4AAAABnQgAAAAAAAAAAAAAAAAAAAIBDQAA8hkAAAGVGQAA\"]Bk[\"\",\"AAAAAAAAAAAAAAAAAAAAAAACCQ0AAEoQAAA=\"]No[\"AAAAAALtHgAADR8AAAONGQAAoRsAAPwcAAACsRwAACIfAAA7MAAAAQAA9wAEAMsQegDiFwAAxhp6ADseAAACBB8AAFsfAAAAOzAAAAEAABAABwBJGQAABBoDAJ0aAwBlHQAANx4BAJUeAwB8IAAAAp0eAACFHwAAAl8dAABwHgAAAY4eAAAAAAFYHQAAAAAAAccZAAAAAAGFIAAA\",\"AAAAAAABpwgAAAABrxAAAA==\"]Ha[\"AAAAAAAAAAABBB8AAAABZR0AAA==\",\"AAAAAWEbAAABch8AAAAEBB0AAJwdAAATHwAAjR8AAALbHgAAiiAAAAABRx0AAAAAAXAeAAABaSAAAAAAAAABPhYAAA==\",1]ANo[\"AAAAAAABbR0AAAXzCgAAuhAAAGsYAACLHwAAfSAAADowAAABAAAAAAAMABAAAADhC+gRuxy8HL0cvhySHhIfrx+wH7Efsh9lIAXnEQAAohQAAK8ZAAAMHwAAJh8AADswAAABAAAMAAYAShkAADMcAwC/HAMAJB8AAHogAQC0IAAAOzAAAAEAAA0ABQBKGwAAORwDAH4eAwDMHgMAayAAAAVJGwAAhR8AAGwgAACYIAAAnyAAAAFwHgAA\",\"AAAAA8cXAACpGQAAkx4AAAHwGAAAAAMEGQAAEx8AAKEgAAADkAsAABIfAABqIAAAAVsfAAAAA0kZAAA3HgAAOB4AAAGdHgAAAl8dAABwHgAAAd8eAAAAAAABUx4AADowAAABAAAAAAAhABAAAADuCFYK4gumDesRQxJKFH0WfhbFF8YX7Bd6GHsYfBh9GJEYmBiZGKkYVRmoGaoZCxqbGkkcShwkHXYddx14HZQerB5xHw==\"]Cg[\"\",\"AAAAAAAAAAAAAAAAAAAAAAAABD4KAABACgAAQQoAADAWAAA=\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAawIAAA=\",\"\"]Cc[\"AAAAAAAAAAAAAAAAAAAAAAAAA5sIAACpCwAAHhYAAA==\",\"\"]Ck[\"AAAAAAAAAAAAAAAAAAAAAAKCFwAAgxcAAAAAAAAAAAAAAegcAAA=\",\"\"]AKa[\"AAAAAAAAAAAAAAAAAScdAAAAAAAAAAAAAAAAAjEZAACFIAAAAAHoHAAA\",\"AAAAAAA7MAAAAQAADQAEAGYYAwATGQMAxBwBAHoeAwACjwsAAJELAAA7MAAAAQAANgAPAKkGAABdDQMAUBQDAEAWAwACGQEAIRkDAKUaAwBwGwMAUxwDALYcAwC7HAMANB0DAJ4dAwByHgMArx8DADswAAABAAAUAAYATBQDAAkZAwBAGQAA4h4DAFQfAwCPHwMAOzAAAAEAABcABgD/CgMAghADAF0bAwAzHAMAvxwDACUeAwA7MAAAAQAAGwAHAAQaAwCdGgMAORwDAEMdAwB+HgMAlR4DAMweAwAAAAAEqSAAAKogAACrIAAArSAAAAbFEAAAxhAAAKUUAACnFAAAvBkAAL0ZAAACghcAAIMXAAA=\",1]Ag[\"\",\"AAAAAAABXwoAAA==\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAYUNAAA=\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAegLAAA=\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAocNAABSEQAA\",\"\"]I@m[\"AAAAAmsWAAB3HwAABKgfAADUHwAAVyAAALEgAAA6MAAAAQAAAAAABwAQAAAAdxYuGUIcTRxbHEIe7h4lHzowAAABAAAAAAAMABAAAAAtA0QDtw1OGbMbqBwlHTAdWh0hHkAe1x48HzowAAABAAAAAAASABAAAABHA28YsBqjG60cuxy8HL0cvhxuHRIfcx+vH7AfsR+yH70fZSB+IDowAAABAAAAAAAbABAAAAA1AzYDTBRNFE4UTxTQFAkZChkLGQwZDhkPGRAZERlhHWMdbx15HTYe6R7rHgQfDB90H4Efrh+dIDowAAABAAAAAAAnABAAAAA3AzkD/woACwELAguCEIMQhBCFEAgZMxw0HDUcNhxsHL8cwBzBHMIc9RxgHSUeJh4nHigedx54HnkemR7FHuEe5h5TH54feiB7IIwgliC0IDowAAABAAAAAAAXABAAAABKG1MbORw6HDscPBxlHUsefh5/HoAegR6hHswezR7OHs8ePR+OH5MflB9aIGsgpCA6MAAAAQAAAAAADAAQAAAARxtJG/IcYh1OHp0enh7qHmwgmCCZIJognyA6MAAAAQAAAAAABwAQAAAAPQOrGgAdgh6DHoQehR6bIDowAAABAAAAAAAHABAAAADvCNoeFR8WHxcfGB+JH7ggOjAAAAEAAAAAAAkAEAAAAKoa2hygHqkgqiCrIKwgrSC1ILYgAb8fAAACSQ0AALwfAAACqhgAAP0cAAAAAAHHGQAAAAABhSAAAA==\",\"AAADdwYAAIEYAAC1HwAAAzEdAABwHwAApSAAADowAAABAAAAAAAYABAAAADNC04NUQ1SDVcNig2BEEsUaBRyFCcWRxanGPAYGxleGUgbbRvPHKQdIx4LHxofch+jIDowAAABAAAAAACAABAAAABCA2ALOxaJFooWjRaOFpAWkhaVFpYWmBaaFp0WnhahFqIWpBanFqkWqxasFq4WsRayFrUWtxa5FrsWvRa/FsAWwxbFFscWyBbKFs0WzxbQFtMW1RbWFtkW2hbdFt4W4BbjFuUW5hboFuoW7RbuFvEW8xb1FvYW+Bb7FvwW/hYAFwMXBBcHFwgXCxcNFw8XERcSFxUXFxcZFxsXHRceFyEXIxclFycXKRcqFy0XLxcwFzMXNBc2FzgXOxc8Fz8XQRdCF0QXRhdJF0oXTBdPF1AXUxdVF1cXWRdbF10XXhdgF2IXZRdmF2gXahdtF28XcBdzF3QXdxd5F3oXfBflF/4Yvxk6MAAAAQAAAAAAEAAQAAAAlwbzCpkNtw0mFk0W6xgXGb8aohuzG2oc1BxkHXAdnB2NHzowAAABAAAAAAAIABAAAAAeA/UK4Qv6GGgcch1PH10fnB8HKBYAAFkfAAB1HwAAXSAAAIcgAACwIAAAtyAAAAADChoAAGUdAACXHwAAAaoeAAABshoAAAKOHgAAaSAAAAG7GQAAAAAAOzAAAAEAAFgBnQA1AAAASAABAFcAAADMAwEAiQYBAKwGAQC4BgEA/AcEAJYIAgCaCAAArAgBALEIAQC4CAAA0wgAAD8JAQBCCQAAPwoAAFcKAABZCgQA5goBAPQKAAAKCwAARwsGAFILAgBYCwAAWgsAAJYLAACeCwAAxwsBAM4LAQDUCwAA4wsAAOoMAADzDAAALQ0AADENAAA0DQMAOQ0CAEENAABLDQIAUw0BAFgNAwBoDQMAcA0EAHYNBACSDQAAmg0BAG8QAQB0EAAAgBAAAIoQAQCNEAAAmBAAAJ4QAQCiEAUAqhABALEQAAC0EAQAvRADAMkQAABIEQAATBEDAMwRAQDQEQMA2REAAN8RAAD2EQEA/BECADESAgA4EgMAPRIDAMYTAQBVFAEAahQCAG8UAgCgFAEAoxQAALUUAADAFAAAwhQAAAwVBgAVFQMAFBYAACMWAAA3FgEARhYAAFYWAABcFgAAZBYGAG0WBQB/FgAAfxcAAIUXBADIFwcA3BcAAOEXAABwGAQAfxgAAIcYAACjGAAAqxgBAO4YAQDxGAIA9hgBAPsYAQAAGQAABRkAAB0ZAAAnGQEAKhkBADsZAwBFGQMASxkBAJAZAACWGQEAmRkAAKMZAQCyGQAAuRkBAMQZAQDPGQAA0hkAANQZAgCZGgAArxoAAMQaAQBCGwMASxsCAE8bAABRGwAAVxsDAG8bAACpGwQAtBsAAEUcAgBRHAEAoRwAAK8cAQDcHAsAJh0AADgdAQB0HQAAkx0AAJYdAgAvHgQAaR4AAGseAACPHgAAkR4AANgeAAD2HgAALB8AADkfAABJHwEApx8AAFMgAgBeIAAA\",1]Go[\"AAAAAAAAAAAAAAAAAScdAAABxBAAAAOkFAAAphQAALsZAAAGxRAAAMYQAAClFAAApxQAALwZAAC9GQAAAoIXAACDFwAAAAAAAAAAAjEZAACFIAAAAAHoHAAA\",\"\"]Cc[\"AAAAAWUIAAABQQMAAAElAAAAAd4CAAA=\",\"AAAAAWUIAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAqoLAAAfFgAA\",\"\"]Cc[\"AAAAAAAAAAAAAAAAAAAAAAAAA7YIAABQDQAAjA0AAA==\",\"\"]Fg[\"AAAAAW0IAAAAAigAAACpEQAAAekCAAAAAAAAAAAAAAAAAAVOAAAAjgYAAK0GAABPFgAA5hwAAA==\",\"AAAAAW0IAAABvxUAAA==\"]Eg[\"AAAAAAAAAAAAADswAAABAADrAQIAWAD1AGgB9QA=\",\"AAAAAAAAAAAAADswAAABAADrAQIAWAD1AGgB9QA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAqULAAAaFgAA\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAbQUAAA=\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAscUAABEHAAA\",\"\"]Bc[\"\",\"AAAAAAAAOzAAAAEAALkAAQBkArkA\"]JNe[\"AAAAAAAAAAHBGQAAAAAAAAEnHQAAAAAAAoIXAACDFwAAAAAAAAAAATEZAAAAAegcAAA=\",\"AAA7MAAAAQAA9QABADwP9QA7MAAAAQAAbgEEAPsDdgBGDvUAhx4AAE4fAAA6MAAAAQAAAAAAhAAQAAAAMwM0A0ED1wgkFScVKBUqFSwVLhUwFTIVNRU3FTkVOxU8FT4VQBVCFUQVRxVJFUoVTRVOFVEVUhVUFVYVWBVbFV0VXxVhFWIVZBVmFWkVaxVsFW4VcRVzFXQVdxV5FXoVfBV/FYAVghWFFYcViBWLFY0VjxWQFZIVlBWWFZkVmhWdFZ4VoRWjFaQVphWpFasVrBWvFbAVshW1FbYVuBW7FbwVvxXBFcIVxRXGFckVyxXNFc4V0BXTFdQV1hXZFdsV3BXeFeEV4hXkFeYV6BXqFewV7xXxFfIV9RX3FfkV+xX8Ff4VABYCFgQWBxYJFgsWDRYPFhEWEhZWGVcZWBlZGVoZbh6pHssejiA7MAAAAQAAIQEtAJYGAACgBgAAvQYBAMIIAADZCAAAmA0AAEkRAQBVEXYA7Rd3AKUYAAD0GAAA+RgAAC8ZAAA/GQAAbhsAAFccAABpHAAA0RwAAE4dAACbHQAANR4AAMceAADTHgAA3B4BAAcfAAAUHwAAHh8AAD4fAABDHwEATB8BAIMfAACqHwAAtB8AALYfAQC5HwEA0x8AAFAgAABgIAAAZiAAAG8gAABzIAAAdSABAH8gAACDIAAAniAAADowAAABAAAAAABNABAAAAChBqIGowakBqYGrga/BsAGwQaTCJUIwwjECMUIxgjICHsNfw3CELYU0RRqGPUYBBlNGZQZoBmuGaQawhpmG2wbohtdHGAcahzGHNMc9hwwHVsdXB0sHi0eQR5HHkoeTR5tHogejB6oHsMe1h4GHyAfIR8tHy4fRR9SH1gffx+AH4sfrB+7H8cfzR9RIFYgWCBnIH0giCCPIKEgpyA7MAAAAQAAywGyAJgGAACaBgAArwYDALQGAABJCgAA+goAAGELAACOCwAAkAsAAK0NAACvDQAAhxAAAOQRAADmEQAAtxQDALwUAAAbFQAAJRUBACkVAAArFQAALRUAAC8VAAAxFQAAMxUBADYVAAA4FQAAOhUAAD0VAAA/FQAAQRUAAEMVAABFFQEASBUAAEsVAQBPFQEAUxUAAFUVAABXFQAAWRUBAFwVAABeFQAAYBUAAGMVAABlFQAAZxUBAGoVAABtFQAAbxUBAHIVAAB1FQEAeBUAAHsVAAB9FQEAgRUAAIMVAQCGFQAAiRUBAIwVAACOFQAAkRUAAJMVAACVFQAAlxUBAJsVAQCfFQEAohUAAKUVAACnFQEAqhUAAK0VAQCxFQAAsxUBALcVAAC5FQEAvRUBAMAVAADDFQEAxxUBAMoVAADMFQAAzxUAANEVAQDVFQAA1xUBANoVAADdFQAA3xUBAOMVAADlFQAA5xUAAOkVAADrFQAA7RUBAPAVAADzFQEA9hUAAPgVAAD6FQAA/RUAAP8VAAABFgAAAxYAAAUWAQAIFgAAChYAAAwWAAAOFgAAEBYAABMWAABEFgAAURYAAOoXAACVGAAAoRgAAA0ZAAAfGQAANxkAAKYZAAC+GQAAwxkAANgZAACcGgAAVBsAAGcbAAB0GwAAoxsAALEbAAC2G3YAMhwAAGgcAABtHAAAqRwAAK0cAADHHAAA0BwAANgcAAD+HAEACB0AAFAdAABmHQAAbh0AAHIdAACUHQAAoh0AAKYdAACoHXYAPR4CAEkeAABQHgAAiR4AAJIeAACbHgAA2x4AAOceAQD1HgAA+B4AAA4fAQAdHwAAHx8AAEYfAABPHwAAXR8CAIgfAACVHwEAnB8BAL0fAADAHwAAWSAAAGogAABuIAAAeSAAAH4gAACKIAEAkyACAJcgAACcIAAApiAAAK4gAAA6MAAAAQAAAAAARAAQAAAAIAN0BnUGdgZKDWENdxDjEecRohQoFlIW6xf4GBIZkhmTGZwZrRmvGQ4aVRtcGzEc2xwFHVIdYR1jHWcdaB1pHWodax1vHXkdNB42HkYeTB5RHpAenx7KHtAe0R7pHuse9B75HkcfWR9bH2AfYx91H4Efih+uH1sgXSCHII0gnSCgIKIgryCwILcgOjAAAAEAAAAAADQAEAAAAKUGlAjHCNIIlQtCEqgUrhQjFVMWmhidGAgZIBlKGbUathpiG2MbThxsHPUc+hxHHVEdUx1gHZUdmh0fHkgedx54HnkemR7SHt4e4R7mHiQfOh9IH0sfUx+eH60fxB94IHogeyCMIJYgtCA6MAAAAQAAAAAAHwAQAAAAmQaoBrMGrg3DEOURuxQaFUkZChpKG1MbVhuyG1gc2Rw3HjgeSx6hHj0fhh+OH5MflB+XH74fayBtIHwgpCCoIDowAAABAAAAAAAjABAAAADdCj8WUBYZGdEZ2hlHG0kbUhu1G/IcVx1iHWwdTh6dHp4eqh7qHhsfHB8/H0AfWh99H4Ufhx+MH7gfbCCCIIQgmCCZIJognyA6MAAAAQAAAAAACwAQAAAAPQOrGq4aAB0nHV8dcB6CHoMehB6FHpsgOjAAAAEAAAAAAAcAEAAAALMIxBDdGdoe3x6JH2kguCA6MAAAAQAAAAAACgAQAAAApBSmFLsZqhraHKAeQh9RH6wgtSC2IAWRGQAA8RwAAE8dAABQHwAAvx8AAANJDQAAWB0AALwfAAACqhgAAP0cAAAAAAHHGQAAAAACMRkAAIUgAAAAAegcAAA=\",1]Gg[\"AAAAAAAAAAAAAAAAAAHEEAAAA6QUAACmFAAAuxkAAAbFEAAAxhAAAKUUAACnFAAAvBkAAL0ZAAA=\",\"AAAAAAWbBgAAwAsAAEwcAABdHQAAXh0AAA==\"]Cg[\"\",\"AAAAAAAAAAAAAAAAAAAAAAAEkRcAAJMcAAAhHQAApR8AAA==\"]Ag[\"AAAAAAAAAS0DAAA=\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAZ4YAAA=\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAb4LAAA=\",\"\"]Do[\"AAAAAAAAAd0CAAAAAAAAAAAAAAAAAAbKCAAAWQ0AAJYNAACoDQAA8xgAAEYbAAA=\",\"AAF2CwAA\"]JKk[\"AAAAArgaAABwHwAABSoDAABKAwAAbRQAAM4ZAADtHgAAB0MDAABICgAATRwAAPAcAADHHgAAJR8AAMwfAAA6MAAAAQAAAAAAIQAQAAAARANFA3sN0RSKF/UYThmeGaAZwhpsG2ccxhwlHTAdWh1bHUEebR6MHsMe1h4GHy4fPB9FH1gfeh/HH1EgWCBnII8gpyA6MAAAAQAAAAAAJgAQAAAAoRiwGlQbaBxtHNAc/hz/HAgdZh1yHZQdPR4+Hj8eSR6JHuce9R4OHw8fTx9dH14fXx+VH5YfnB/AH1kgbiB5IH4giyCUIJUglyCcIK4gOjAAAAEAAAAAABkAEAAAADYDrRlVGwUdUh15HTYeRh5MHmoekB6fHtAe0R70HvkeRx9gH4cgjSCdIKAgoiCvILAgtyA6MAAAAQAAAAAAFwAQAAAAQhIIGWwcUR1THZUdSB6ZHtIe3h7hHuYeJB86H0sfUx+tH8QfeCB6IHsgjCCWILQgOjAAAAEAAAAAABoAEAAAABoVBBoFGgYaBxqdGp4anxqgGlMbNx44HkselR6WHpcemB6hHvIehh+OH5MflB9rIG0gpCCoIDowAAABAAAAAAARABAAAAAZGdEZ2hlJG7Ub8hxOHqoeGx8cH1ofhx+4H2wgmCCZIJognyADqxoAAAAdAACbIAAAA9oeAACJHwAAuCAAAAaqGgAA2hwAAKAeAABCHwAAUR8AALUgAAAFkRkAAPEcAABPHQAAUB8AAL8fAAADghcAAIMXAAC8HwAAAf0cAAAAAAAAAAGFIAAA\",\"AAGnHQAAOjAAAAEAAAAAAAoAEAAAAO4R7xHwEfER8hEkFi4csxy1HCQefh86MAAAAQAAAAAAFwAQAAAA8ApADRkVjxiUGJ0ZwBnGGQ0asRphG1wc6RzzHPQcSB1ZHXwdPB6HHo0eTh9wH5AgOjAAAAEAAAAAABMAEAAAADMDNAPXCJILzQtoFFYZVxlYGVkZWhl1G6Qdbh6pHsseCx8aH3IfjiA6MAAAAQAAAAAALAAQAAAAvQbZCOIMSRFKEaUY9Bj5GC8ZPxm/GaEbVxxpHNEcTh2bHTUexx7cHt0eBx8UHx4fPh9MH00fgx+qH7Qfth+3H7kfuh/TH1AgYCBmIG8gcyB1IHYgfyCDIJ4gOjAAAAEAAAAAADwAEAAAAHsNfw3RFCYWahj1GAQZTRmUGaAZrhmkGsIabBuiG10cahyoHLEcxhzTHPYcMB1bHWQdIR4sHi0eQR5HHkoeTR5tHogejB6oHsMe1h4GHxMfIB8hHy0fLh9FH1IfWB+LH6wfux/HH80fUSBWIFggZyB9IIggjyChIKcgOjAAAAEAAAAAAFAAEAAAAEkK+gphC44LkAuHEBsVRBZRFpUYoRgNGTcZphm+GcMZ2BmcGlQbdBujGzIcaBxtHKkcrRzHHNAc2Bz+HP8cCB1QHWYdch2UHaIdPR4+Hj8eSR5QHokekh6bHtse5x71HvgeDh8PHx0fHx9GH08fXR9eH18fcx+IH5Uflh+cH50fvR/AH1kgZSBqIG4geSB+IIogiyCTIJQglSCXIJwgpiCuIDowAAABAAAAAAAzABAAAAAgA3cQ4xEoFlIW6xf4GA4ZDxkQGREZEhmSGZMZrRmvGQ4aVRtcG9scBR1SHXkdNB42HkYeTB5RHmoekB6fHtAe0R70HvkeDB9HH2AfYx90H3Ufih9bIF0ghyCNIJ0goCCiIK8gsCC3IDowAAABAAAAAAAjABAAAACVC0ISqBQjFVMWmhgIGSAZYhtsHPocRx1RHVMdlR2aHUgemR7SHt4e4R7mHiQfOh9IH0sfUx+eH60fxB94IHogeyCMIJYgtCA6MAAAAQAAAAAAFwAQAAAAqAYaFQoaUxuyGz0cPhw/HEAcWBzZHEseoR6GH44fkx+UH5cfvh9aIGsgbSCkIKggOjAAAAEAAAAAAB0AEAAAAN0KPxZQFhkZ0RnaGUcbSRtSG7Ub8hxOHp4eqh4bHxwfPx9AH1offR+FH4cfuB9sIIIghCCYIJkgmiCfIAerGgAArhoAAAAdAABwHgAAhB4AAIUeAACbIAAAOjAAAAEAAAAAAAsAEAAAALMIxBDdGdoe3x4VHxYfFx8YH4kfaSC4IDowAAABAAAAAAAKABAAAACkFKYUuxmqGtocoB5CH1EfrCC1ILYgBZEZAADxHAAATx0AAFAfAAC/HwAAAlgdAAC8HwAAAf0cAAA7MAAAAQAA8wBaADcAAwA8AAcARQABAEoADADKAwAA0QMFAH0GAAB/BgkAiwYKAJwGAACbCAEArggAALAIAADdCAAA4wgAAOkIBAA8CQAAQQkAAEsKAABYCgAA3goAAO0KAQAGCwAACAsAAF0LAACUCwAAlwsGAJ8LFAC2CwkA6wsAAOMMBgDrDAEA7gwBAPEMAQAuDQIAMg0BADgNAACCDQIAnw0AAEQOAQAyEAIAcRACAHUQAAB7EAMAqBABAKwQAgC5EAAAuxABAMEQAADHEAAAzhEBANQRBADaEQQA4BECADQSAwA8EgAAVxQAAG4UAABzFAEArxQBALIUAgDFFAQAzBQAABMVAQA8FgAARRYAAE4WAQBUFgEAVxYBAHMWAgCBFgIAhBcAAL8XBQDVFwAA4xcAAIAYAACTGAAAlhgBAJ4YAACmGAAAOBkCAKwZAACaGgAArBoAAMMaAABOGwAAUBsAAEQcAABZHAEAPx0BAAABxxkAAAAAAYUgAAA=\"]Cg[\"AAAAAAAAAAAAAAAAAAAAAAAABGMCAAD5CAAAYAoAAOkLAAA=\",\"\"]Cc[\"AAAAAAAAAAAAAAAAAAAAAAAAA28GAACREAAAzxEAAA==\",\"\"]Ao[\"\",\"AAA7MAAAAQAA9QABAOwL9QA=\"]Ak[\"AAAAAAAAAAABNxwAAA==\",\"\"]Ck[\"AAAAAAEwGQAAAAAAAjccAABDHgAA\",\"AAAAAAAB0wsAAAAAATccAAA=\"]Ac[\"\",\"AAAB0BkAAA==\"]Ak[\"AAAAAAAAAAAAAXYEAAA=\",\"\"]Cc[\"\",\"AAAAAAAAAaENAAAAAAAAAAAAAAAAAALUCAAASR0AAA==\"]Co[\"AAAAAAAAAZ4ZAAAAAjkeAABqHgAAAAHyHgAA\",\"AAABtRwAAAAAAAFxHgAA\"]Hc[\"AAAAAWoIAAAAAaYRAAAB5gIAAAABWx8AAAAAAAAAAAAAAAVMAAAAjAYAAIsQAACaGgAArxoAAA==\",\"AAAAAWoIAAABuBUAAAADRAMAAH8fAACAHwAAApUfAACuIAAA\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAbQIAAA=\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAbsLAAA=\",\"\"]Io[\"AAABLwMAAAFLAwAAApsGAADXCAAAAAAAAAAB8h4AAA==\",\"AAF2HgAAAAGQGAAAAp0GAACzEAAAAfkYAAAAApwaAACbHgAAAAHSCAAAAAABshoAAAAAAAAABZ4GAABuEAAAfxAAAIUWAACvGwAAAAHHGQAA\"]Cc[\"AAAAAAAAAAAAAAAAAAAAAAAAA7UIAABPDQAAiw0AAA==\",\"\"]Ao[\"AAAAAAAAAAAAAnYEAACVCwAA\",\"\"]Ag[\"AAAAAAAAAVwdAAA=\",\"\"]Ag[\"AAAAAAAAAV4KAAA=\",\"\"]AFg[\"AAAAAAAAADowAAABAAAAAAB3ABAAAAAlFSYVKRUrFS0VLxUxFTMVNBU2FTgVOhU9FT8VQRVDFUUVRhVIFUsVTBVPFVAVUxVVFVcVWRVaFVwVXhVgFWMVZRVnFWgVahVtFW8VcBVyFXUVdhV4FXsVfRV+FYEVgxWEFYYViRWKFYwVjhWRFZMVlRWXFZgVmxWcFZ8VoBWiFaUVpxWoFaoVrRWuFbEVsxW0FbcVuRW6Fb0VvhXAFcMVxBXHFcgVyhXMFc8V0RXSFdUV1xXYFdoV3RXfFeAV4xXlFecV6RXrFe0V7hXwFfMV9BX2FfgV+hX9Ff8VARYDFgUWBhYIFgoWDBYOFhAWExY=\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAArcIAAAJCwAA\",\"\"]Ic[\"AAAAAQ0IAAAAAlkRAAB3FgAAAWcCAAABoxsAAAAAAAAAAAAAAAA6MAAAAQAAAAAABwAQAAAA4wwyEDUQ1hGvFHMWwBeKGA==\",\"AAAAAQ0IAAACKhUAACcWAAAAAaIbAAAAAAABChoAAA==\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAqYLAAAbFgAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAArELAAC4GQAA\",\"\"]Ha[\"AAAAAAEwGQAAOzAAAAEAAIoAAgBCAwAADxqJAAFECQAAAAI3HAAAQx4AAA==\",\"AAAAAescAAAAAdMLAAAAAAE3HAAAAAAAAAAAAAAAA18CAAAmAwAA1hcAAA==\",1]BBg[\"AAAAAAAAADswAAABAAD1AAIAyxB6AMYaegA=\",\"AAAAAAA7MAAAAQAA9QBbAGIKegCJFgEAjRYBAJAWAACSFgAAlRYBAJgWAACaFgAAnRYBAKEWAQCkFgAApxYAAKkWAACrFgEArhYAALEWAQC1FgAAtxYAALkWAAC7FgAAvRYAAL8WAQDDFgAAxRYAAMcWAQDKFgAAzRYAAM8WAQDTFgAA1RYBANkWAQDdFgEA4BYAAOMWAADlFgEA6BYAAOoWAADtFgEA8RYAAPMWAAD1FgEA+BYAAPsWAQD+FgAAABcAAAMXAQAHFwEACxcAAA0XAAAPFwAAERcBABUXAAAXFwAAGRcAABsXAAAdFwEAIRcAACMXAAAlFwAAJxcAACkXAQAtFwAALxcBADMXAQA2FwAAOBcAADsXAQA/FwAAQRcBAEQXAABGFwAASRcBAEwXAABPFwEAUxcAAFUXAABXFwAAWRcAAFsXAABdFwEAYBcAAGIXAABlFwEAaBcAAGoXAABtFwAAbxcBAHMXAQB3FwAAeRcBAHwXAAA=\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAb8LAAA=\",\"\"]Do[\"AAAAAdULAAADnQYAAMALAACBEAAAAAAAAAAAAAAAAAAAAAJzBAAAngYAAA==\",\"AAAAAAGbBgAA\"]Ag[\"AAAAAAAAAAFvGAAA\",\"\"]Ic[\"AAABYRkAAAGAFgAAAAAAA7EGAAC0BgAAbxgAAAAAAbMGAAA=\",\"AAABIh4AAAExHQAAASIdAAAAAAAAAAAAAAAAAAAEdxwAAA0dAAD/HgAAox8AAAXaCAAA6gsAAJANAACWDQAABx0AAA==\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAUcRAAA=\",\"\"]Dg[\"AAAAAYkIAAABnQgAAAIxAAAAxREAAAETAwAA\",\"AAAAAYkIAAABBxYAAAAAAdseAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAqMLAAA2FgAA\",\"\"]Bc[\"AAAAAAA7MAAAAQAAegABAGIKegA=\",\"\"]Bg[\"\",\"AAAAAAAAAAAAAAAAAAAAAAABlhcAAA==\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAb0LAAA=\",\"\"]Bk[\"\",\"AAAAAAAAAAAAAAAAAAAAAAACGBIAAGcfAAA=\"]Bc[\"\",\"AAAAAAFXCwAAAV4CAAABTRYAAA==\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAlUWAAA5GQAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAlQWAAA4GQAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAlcWAAA6GQAA\",\"\"]Ck[\"AAAAAAAAAAAAAAAAAAAAAAAABacLAACeDQAAHBYAAH8XAACnGwAA\",\"\"]Cc[\"AAAAAAAAAAAAAAAAAAAAAAAAA7ALAACbEAAAtxkAAA==\",\"\"]Dc[\"AAAAAS4IAAAAAQwAAAABoAIAAAGpBgAA\",\"AAAAAS4IAAAAAAAAAAAAAUcbAAA=\"]Bk[\"AAAAAAABFAAAAAHAAgAA\",\"AAAAAAABnBgAAA==\"]Ag[\"\",\"AAAAAAAAAVwdAAA=\"]Ac[\"\",\"AAAAAZkIAAA=\"]Ao[\"\",\"ADswAAABAAB6AAEATwN6AA==\"]Bc[\"AAAAAAAAAAGpBgAAAAABshsAAA==\",\"\"]Hc[\"AAAAAWsIAAABqB8AAAGnEQAAAecCAAAAAAAAAAAAAAAAADowAAABAAAAAAAHABAAAACeEKAUNxZGHDMekR7YHkkf\",\"AAF6CwAAATkFAAABawgAAAG7FQAAAAEXGQAA\"]Fg[\"AAAAAR4IAAAAAXIRAAABjwIAAAAAAAAAAAAAAAAABEAAAAAzEAAAjBAAANoRAAA=\",\"AAAAAR4IAAABWBUAAAAAAv4cAABdHwAA\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAcgGAAA=\",\"\"]BDk[\"AAAEPAMAAPEeAADzHgAAKB8AAAOYHwAAxR8AALIgAAADbh4AAKkeAADLHgAABkkRAABKEQAAyhkAAMsZAADHHgAAgx8AAAQtAwAAMB0AACEeAAB6HwAAOjAAAAEAAAAAAHcAEAAAACUVJhUpFSsVLRUvFTEVMxU0FTYVOBU6FT0VPxVBFUMVRRVGFUgVSxVMFU8VUBVTFVUVVxVZFVoVXBVeFWAVYxVlFWcVaBVqFW0VbxVwFXIVdRV2FXgVexV9FX4VgRWDFYQVhhWJFYoVjBWOFZEVkxWVFZcVmBWbFZwVnxWgFaIVpRWnFagVqhWtFa4VsRWzFbQVtxW5FboVvRW+FcAVwxXEFccVyBXKFcwVzxXRFdIV1RXXFdgV2hXdFd8V4BXjFeUV5xXpFesV7RXuFfAV8xX0FfYV+BX6Ff0V/xUBFgMWBRYGFggWChYMFg4WEBYTFgE5HgAABGIbAAD6HAAAxR4AADofAAAC8h4AAJMfAAAF0RkAALUbAADyHAAAPx8AAFofAAAAAAJCHwAAUR8AAAGRGQAA\",\"AAAAAAAAAAAAAAAAAAAAAAAAAUESAAA=\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAckLAAA=\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAqsLAAAgFgAA\",\"\"]Cc[\"AAAAAAAAAAAAAAAAAAAAAAAAA6gLAAB+EAAAHRYAAA==\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAdUDAAA=\",\"\"]Do[\"AAAAAU8IAAABzBkAAAIXAAAAlREAAALGAgAA1x4AAA==\",\"AAAAAU8IAAADmRUAAEgbAABtGwAA\"]Bg[\"\",\"AAAAAAAAADswAAABAAB2AAEAqB12AA==\"]Cc[\"AAAAAUYIAAAAARMAAAABuwIAAA==\",\"AAAAAkYIAACMGQAA\"]Bc[\"AAAAAAAAOzAAAAEAALkAAQBkArkA\",\"\"]Bg[\"AAAAAAHNCwAAAAEBAwAA\",\"AAAAAAHvFQAA\"]Ek[\"AAAAAXkIAAABQwkAAAG1EQAAAfUCAAAABA4ZAAAPGQAAEBkAABEZAAAAAZQfAAA=\",\"AAAAAXkIAAAB1hUAAA==\"]Fo[\"AAAAAX0IAAAAAbkRAAAB+QIAAAAAAAAAAAAAAAAABJIGAACUCwAA6wsAADwWAAA=\",\"AAAAAX0IAAAB3hUAAAAAAAADtRoAALYaAADEHwAA\"]AIk[\"AAAAOjAAAAEAAAAAAAcAEAAAADsDpgRgCJQYnRkpHioedyAAOjAAAAEAAAAAAAgAEAAAACIAnhE7Frka7xx6HUQeRR5kHwTYAgAAwhAAAGAcAABnHAAAA2cBAADoEQAAHxkAAAZ0BgAAdQYAAHYGAADnEQAAohQAACYfAAAAAUobAAABhR8AAAFwHgAAAAAAAAA6MAAAAQAAAAAAGAAQAAAASgCKBv8H7AhCCQULSgs3DVgNaQ2rENAR3xHqEfcROxJ1FBIVVhaFF4kXzRerGMsc5Bw=\",\"AAAAAWAIAAABqxUAAAOnCAAAYAsAAOUXAAAElwYAAPMKAACZDQAAaxgAAAPhCwAArxAAAK0cAAAAAAAAAAAAAAAAOzAAAAEAAAYAAwBuBgEAtAgDALkIAAA=\"]AKc[\"AAABchwAAAeWBAAAbggAAKAQAAChEAAAXBQAACoWAACfGQAAAAWqEQAApxwAAPwcAACbHwAAciAAAALqAgAAnhkAAAE7HgAAAAFOHAAAAUkZAAAAAV8dAAABjh4AAAAAAAA6MAAAAQAAAAAAIgAQAAAATwByBo8GWwrmCkwLOg1qDXkNlBCtEL8QThHSEeIR/BE+EmwUIxZpFnEWyxfOF3MYJxkqGT0ZSBlEG1kbqxvnHJcdMR5UIA==\",\"AAF7CwAAAXIcAAACbggAALgaAAACwRUAAKgfAAAAAAM/HgAA5x4AAJcgAAAFMRwAAMoeAABZHwAAjSAAALcgAAADShkAAHogAAC0IAAAAj0fAABrIAAAA2wgAACYIAAAnyAAAAACjh4AAGkgAAAAAAAABXAGAACNGAAAjhgAAKEaAACiGgAA\"]Fg[\"AAACsBAAAHUYAAABNQgAAAACDgAAAIYRAAABqgIAAA==\",\"AAFlCwAAAAE1CAAAAoAVAAA8HQAAAAAAAAAAAAAAAAAAAAFyBgAA\"]Eg[\"AAABoRkAAAFhCAAAAAIjAAAAnxEAAAHZAgAA\",\"AAAAAWEIAAABrBUAAAAAAAAAAAAAAAAAAAABdRQAAA==\"]Ig[\"AAAAA7YNAADbFwAApBgAAAAAA6YGAACVCAAAyAgAAAWaBgAAtAYAAK8NAADmEQAAvBQAAAJjHQAA6x4AAAAAAAAAAAAAAAcnAwAAqwYAAMEIAAADCwAAFgsAABgVAADWGQAA\",\"AAAAAAFBAwAA\"]Ag[\"AAAAAAABshAAAA==\",\"\"]Fk[\"AAAAAUQIAAAAAZARAAABuQIAAAAAAAAAAAAAAAAAB0ENAABrDQAAmBAAAFwWAACjGAAArBgAALIZAAA=\",\"AAAAAUQIAAABlBUAAA==\"]Eo[\"AAAAATAIAAAAAAGiAgAAAAAAAAAAAAAAAAAFRAAAAJ4LAADICwAAvBAAAGEWAAA=\",\"AAAAATAIAAAAAAAAAAGoFAAA\"]Bg[\"\",\"AAAAAAAAADswAAABAAB6AAEAyxB6AA==\"]Fg[\"AAAAAYQIAAAAAiwAAADBEQAAAQ4DAAAAAAAAAAAAAAAAAAXQCwAAcRQAABUVAABGFgAAiBcAAA==\",\"AAAAAYQIAAAB/hUAAA==\"]AEo[\"AAAAAWkIAAAAAaURAAAC5AIAAOUCAAABwRkAAAAAAAABJx0AAAAAAAAAAAAAAAABMRkAAAAB6BwAAA==\",\"AAAAAWkIAAABthUAAAJDHwAARB8AAAJ/HwAAgB8AAAA6MAAAAQAAAAAABwAQAAAAYQ2cGTEcyh5ZH1sfgR+uHzowAAABAAAAAAAJABAAAABKGbUathpOHPUcRx1gHXceeB55HgfDEAAASRkAAFYbAAA3HgAAOB4AAD0fAAB8IAAAA1cdAACdHgAAjB8AAAYnHQAAXx0AAIIeAACDHgAAhB4AAIUeAAAAAqwgAAC2IAAAAAAAAAAAAAABMRkAAAAB6BwAAA==\"]Ac[\"\",\"AAABtxoAAA==\"]Cc[\"AAAAAAAAAAAAAAOoBgAAwxAAAFYbAAACVx0AAIwfAAA=\",\"\"]Eo[\"AAAAAYIIAAAAAioAAAC/EQAAAQwDAAABsBoAAA==\",\"AAAAAYIIAAAB+xUAAAAAAAAAAAAAAAAAAAAC+BEAAPkRAAA=\"]ACFe[\"AAA7MAAAAQAAhAEDAOwL9QC6DYkA7hEEADswAAABAAASAngAHwMAACMDAABIAwAAlgQAAKYEAACoBAAAuwYAAMIGAAAHCAAACQiJAL0IAADWCAAA2wgAAN8IAgDkCAAARQn1APAKAAAOCwIAFAsAAIgLAQDVCwAAQA0AAGINAABnDQAAiQ0AAI0NAQCUDQAAog0AALQNAAC2DQAAiBABAI4QAACSEAAAnBAAAKAQAQBUFAAAWxQBAGAUAABjFAIAGRUAABwVAAApFgQATBYAAF0WAgCEFgAAxxcAANcXAQDbFwAA5xcAAG4YAAB2GAAAeBgBAI8YAQCUGAAAohgAAKQYAADsGAAAHBkAAIMZAgCHGQAAiRkAAIwZAACdGQAAnxkAAKcZAACpGQAAwBkAAMIZAADGGQAA2xkAAA0aAACxGgAAuhoBAGsbAAA4HAAAQRwAAEMcAABcHAAAZBwBAKAcAACiHAAAuhwAAMMcAADNHAAA6RwAAPMcAQD7HAAAKx0CADMdAABIHQAAWR0AAHEdAAB8HQAAnR0AACkeAQA8HgAAhh4AAI0eAACTHgAAmh4AAMgeAQDwHgAAJx8AAEEfAABhHwAAeR8AAHwfAACCHwAAmB8AAJofAAChHwAAqR8AAMEfAADFHwAAyx8AAFIgAABfIAAAdyAAAJAgAACyIAAAOzAAAAEAANUBqwBOAwAAdwQGAJsGAACdBgAAtgYBAAgIAACdCAAArwgAANAIAQBDCQAAPQoAAEwKAABRCgEA+woAAFcLAABbCwAAXgsAAIoLAACSCwAAwAsAAM0LAADtDAAA8AwAAE4NAABRDQEAVw0AAIoNAACsDQAAuA0AAIEQAACzEAAAzBN7AEsUAAByFAAAIRYAACcWAABHFgAAiBYAAIsWAQCPFgAAkRYAAJMWAQCXFgAAmRYAAJsWAQCfFgEAoxYAAKUWAQCoFgAAqhYAAK0WAACvFgEAsxYBALYWAAC4FgAAuhYAALwWAAC+FgAAwRYBAMQWAADGFgAAyRYAAMsWAQDOFgAA0RYBANQWAADXFgEA2xYBAN8WAADhFgEA5BYAAOcWAADpFgAA6xYBAO8WAQDyFgAA9BYAAPcWAAD5FgEA/RYAAP8WAAABFwEABRcBAAkXAQAMFwAADhcAABAXAAATFwEAFhcAABgXAAAaFwAAHBcAAB8XAQAiFwAAJBcAACYXAAAoFwAAKxcBAC4XAAAxFwEANRcAADcXAAA5FwEAPRcBAEAXAABDFwAARRcAAEcXAQBLFwAATRcBAFEXAQBUFwAAVhcAAFgXAABaFwAAXBcAAF8XAABhFwAAYxcBAGcXAABpFwAAaxcBAG4XAABxFwEAdRcBAHgXAAB7FwAAfRcAAOgXAACnGAAA8BgAABsZAABDGQAAVhkFAF4ZAADMGQAAzhkAANwZAAAIGgEASBsAAGkbAABtGwAAdRsAAEwcAABiHAEAcRwAAK4cAADPHAAA6hwAACIdAAAoHQAAPB0AAFQdAABdHQEApB0AACMeAAAuHgAAUh4AAJweAADsHgEABR8AAAsfAAANHwAAEB8AAHsfAACfHwEAwh8BAMgfAQDVH3oAoyAAALEgAAA7MAAAAQAAAASoAAoAKQBeAgAAlgYAAJ8GAQC9BgEApwgAAMIIAADNCAAA2QgAAEgKAABfCgAAYgp6AGALAACGCwAA0wsAAOIMAACYDQAAhhAAALIQAADIEAAASREBAFURdgBEEvUAOxOKANIUAAA7FgAAdhYBAIkWAQCNFgEAkBYAAJIWAACVFgEAmBYAAJoWAACdFgEAoRYBAKQWAACnFgAAqRYAAKsWAQCuFgAAsRYBALUWAAC3FgAAuRYAALsWAAC9FgAAvxYBAMMWAADFFgAAxxYBAMoWAADNFgAAzxYBANMWAADVFgEA2RYBAN0WAQDgFgAA4xYAAOUWAQDoFgAA6hYAAO0WAQDxFgAA8xYAAPUWAQD4FgAA+xYBAP4WAAAAFwAAAxcBAAcXAQALFwAADRcAAA8XAAARFwEAFRcAABcXAAAZFwAAGxcAAB0XAQAhFwAAIxcAACUXAAAnFwAAKRcBAC0XAAAvFwEAMxcBADYXAAA4FwAAOxcBAD8XAABBFwEARBcAAEYXAABJFwEATBcAAE8XAQBTFwAAVRcAAFcXAABZFwAAWxcAAF0XAQBgFwAAYhcAAGUXAQBoFwAAahcAAG0XAABvFwEAcxcBAHcXAAB5FwEAfBcAAOUXAABmGAMAnBgAAPkYAAD+GAAAExkDACUZAQAuGQEAPxkAAE8ZAACNGQAAmhkBAL8ZAADKGQEAzRkAANMZAAAPGokAuRoAAFsbAABoGwAAbhsAAKEbAABNHAAAWxwAAGEcAACnHAAAxBwBANEcAADvHAEA9xwCAPwcAAAyHQAAbR0AAHodAABCHgAARB4BAGweAAB6HgMA0x4AAOAeAADuHgAAJR8AAEMfAQBkHwAAgx8AAJsfAACzHwAAzB8AANMfAABgIAAAciAAADswAAABAAAAAT0AZAK5AJcGAAChBgMApgYAAK4GAAC/BgIAkwgAAJUIAADDCAMAyAgAAEQJAABeCgAA7woAAPMKAACPCwAAkQsAAH8NAACZDQAAtQ0AALcNAAC6EAAAwhAAALYUAAAmFgAATRYAANkXAABqGAEA6xgAAAQZAAAXGQAATRkBAIsZAACeGQAAvxoAAGYbAACzGwAAXRwAAGAcAABnHAAAqBwAALEcAADUHAAAJR0AAFodAABcHQAAZB0AAHAdAACcHQAAIR4AAC0eAABBHgAAcR4AANceAAATHwAAIh8AADwfAAB6HwAAfx8BAIQfAACNHwAAoSAAADswAAABAADSAqYAZwEAAB4DAACYBgAAmgYAAKkGAACvBgMAtAYAAEkKAAD1CgAA+goAAGELAACOCwAAkAsAAOELAABdDQMArQ0AAK8NAACHEAAArxAAAMsQegDkEQAA5hEAAOgRAABQFAMAtxQDALwUAAAbFQAAJRUBACkVAAArFQAALRUAAC8VAAAxFQAAMxUBADYVAAA4FQAAOhUAAD0VAAA/FQAAQRUAAEMVAABFFQEASBUAAEsVAQBPFQEAUxUAAFUVAABXFQAAWRUBAFwVAABeFQAAYBUAAGMVAABlFQAAZxUBAGoVAABtFQAAbxUBAHIVAAB1FQEAeBUAAHsVAAB9FQEAgRUAAIMVAQCGFQAAiRUBAIwVAACOFQAAkRUAAJMVAACVFQAAlxUBAJsVAQCfFQEAohUAAKUVAACnFQEAqhUAAK0VAQCxFQAAsxUBALcVAAC5FQEAvRUBAMAVAADDFQEAxxUBAMoVAADMFQAAzxUAANEVAQDVFQAA1xUBANoVAADdFQAA3xUBAOMVAADlFQAA5xUAAOkVAADrFQAA7RUBAPAVAADzFQEA9hUAAPgVAAD6FQAA/RUAAP8VAAABFgAAAxYAAAUWAQAIFgAAChYAAAwWAAAOFgAAEBYAABMWAABAFgQA4hcAAOoXAABvGAAAoRgAAPoYAAACGQEADRkAAB8ZAAAhGQMAphkAAL4ZAADBGQAA2BkAAJwaAAClGgMAxhp6AGcbAABwGwQAsRsAALYbdgAyHAAAUxwDAKkcAACtHAAAthwDALscAwDHHAAACB0AADQdAwBmHQAAbh0AAJ4dBACmHQAAqB12ADseAAByHgMAmx4AAOgeAAASHwAARh8AAHMfAACZHwAArx8DAGUgAABqIAAApiAAADowAAABAAAAAABFABAAAAAgA3QGdQZ2BkoNYQ13EOMR5xFMFE0UThRPFKIU0BQoFusXCRkKGQsZDBkOGQ8ZEBkRGRIZQBmSGZMZnBlcGzEcYR1jHWcdaB1pHWodax1vHXkdQx5qHp8eyh7iHuMe5B7lHuke6x4EHwwfJh9UH1UfVh9XH1kfWx9jH3QfdR+BH48fkB+RH5Ifrh9bIDowAAABAAAAAAA1ABAAAAB2BKUGlAjHCNII/woACwELAguVC4IQgxCEEIUQqBSuFCMVmhidGCAZShm1GrYaXRteG18bYBtjGzMcNBw1HDYcThy/HMAcwRzCHPUcRx1gHZodHx4lHiYeJx4oHnceeB55HtIe4R5IH1Mfnh86MAAAAQAAAAAAMwAQAAAAmQaoBrMGrg3DEOURuxRJGQQaBRoGGgcanRqeGp8aoBpKG1Ybshs5HDocOxw8HD0cPhw/HEAcKh1DHUQdRR1GHWUdNx44Hn4efx6AHoEelR6WHpcemB7MHs0ezh7PHj0fjh+XH1ogfCA6MAAAAQAAAAAADgAQAAAA3Qo/FkcbSRtXHWIdbB2dHp4e6h5AH30fhR+MH4IgOjAAAAEAAAAAAAgAEAAAAK4ashonHV8dcB6CHoMehB6FHjowAAABAAAAAAAKABAAAACzCO8IxBDdGY4e3x4VHxYfFx8YH2kgOjAAAAEAAAAAAAgAEAAAAKQUphS7GakgqiCrIKwgrSC2IAbFEAAAxhAAAKUUAACnFAAAvBkAAL0ZAAAESQ0AAIIXAACDFwAAWB0AAAGqGAAAAAABxxkAAAAAAjEZAACFIAAAAAHoHAAA\",\"AAADsxoAALQaAAC3GgAAOzAAAAEAAPYAAgBFCfUAaRQAADowAAABAAAAAAAHABAAAACdBq8IQwlMClsLXgvtDPAMOzAAAAEAAPoABgCnCAAAYAsAAEQS9QA7FgAA5RcAAC4ZAAAFlwYAAJkNAAChDQAAaxgAAAQdAAAD4QsAAK8QAADiFwAAAAABZR0AAAAAAAAAADswAAABAAAxABgA9QwBAAANAAAfDQAAQRAAABYSAQDTFAEAtxcAAMAYFgB5GQAAlxsAAJwbAACQHAEADh0AAIIdAACRHQAAVB4AAFkeAABcHgAAaB4AALMeAAC5HgAANB8AAGwfAACkHwAAA3QEAABOCwAAfA0AAA==\",1]Gg[\"AAAAARMIAAAAAgoAAAChGwAAAXMCAAAAAAAAAAAAAAABWB0AAAAFPAAAAIIGAAAzEgAADxUAAHAYAAA=\",\"AAAAApYEAAATCAAAAAAAAAABmh0AAA==\"]Eg[\"AAAAAWwIAAAAAicAAACoEQAAAegCAAAAAAAAAAAAAAAAAAJNAAAAjQYAAA==\",\"AAAAAWwIAAABvBUAAA==\"]Ek[\"AAAAAAAAAAAAAAAAAAAAAAFJDQAAAaoYAAA=\",\"AAAAAaANAAAAAAGhDQAAAAAAAAAAAAAAAAKAHAAA/R4AAA==\"]Gc[\"AAAAAQwIAAAAAVgRAAABZgIAAAAAAAAAAAAAAAAAA8YGAADVEQAAvxcAAA==\",\"AAAAAQwIAAABKBUAAAAAAZQdAAAAAUISAAAAAAABswgAAA==\"]Ko[\"AAAAASMIAAAAAXcRAAABlAIAAAH/HAAAAAAAAAAAAAAAADowAAABAAAAAAAVABAAAABCAIUGWQpHCzQNdg2oEL0QTBHNETgSahRmFm4WyBdxGEUZQhtXG6kb4BwvHg==\",\"AAAAASMIAAACYhUAAF0dAAAAAAFoHAAAAAABoR4AAAAAAAAAAAH9HAAA\"]Ic[\"AAAAAAGoHwAAAAHjAgAAAAAAAAAAAAAAAAA6MAAAAQAAAAAAGgAQAAAASwCLBloKSws5DXgNkxCsEL4QTRHREeERPRJrFGgWcBbKF3IYPBlHGUMbWBuqG+Uclh0wHlMg\",\"AAAAAAFeHQAA\"]Ck[\"AAAAAUgDAAABSQMAAAABBBkAAAAAAAAAAAHEEAAAAqQUAAC7GQAA\",\"\"]Fg[\"AAACKQMAACwDAAABRwgAAAABnwYAAAJFAwAAXB0AAAABEhkAAAAAAAAAAAAAAAJACgAARgoAAA==\",\"AAAAAUcIAAABNAMAAA==\"]Fg[\"AAAAAUUIAAABNAMAAAABugIAAAAAAAAAAAAAAAAAAuoLAABJFgAA\",\"AAFqCwAAAAFFCAAAAAAAAAAAAAAAAAAAAAABRgoAAA==\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAaELAAA=\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAbILAAA=\",\"\"]Jo[\"AAAABeQIAAAUCwAAlA0AAIkZAABkHAAAAngEAABXDQAAAAOkBgAAwQYAAMYIAAACsgYAALoUAAACdgYAAGsdAAAAAAAAAAAAAAA6MAAAAQAAAAAACAAQAAAAJQPlCHUNkhgAGYoZohnWHFYd\",\"AAAAAAAAAAAAAAAAAT0DAAA=\"]Ag[\"\",\"AAAAAAABshAAAA==\"]Cc[\"AAAAAY8IAAAAAY0ZAAACGAMAAIoXAAA=\",\"AAAAAY8IAAA=\"]Gc[\"AAAAAVoIAAAAAh0AAACZEQAAAdICAAA=\",\"AAFyCwAAAANaCAAAXRYAAF4WAAABoRUAAAAAAAAAAAAAAAAAAAAE9goAAPcKAAD4CgAA+QoAAA==\"]Ak[\"AAAAAAACyBAAANIUAAA=\",\"\"]Bc[\"AAAAAAA7MAAAAQAAdgABAFURdgA=\",\"\"]Bk[\"AAAAAAAAADswAAABAADtAAIAtht2AKgddgA=\",\"\"]Fg[\"AAAAAQoIAAAAAVYRAAABZAIAAAAAAAAAAAAAAAAABDcAAAB/BgAA8QwAAKwZAAA=\",\"AAAAAQoIAAABJBUAAAABBh8AAAGpHAAA\"]Fc[\"AAAAAR0IAAAAAXERAAABjgIAAAAAAAAAAAAAAAAABD8AAADRAwAAgwYAAPMMAAA=\",\"AAAAAR0IAAABVhUAAAABRx4AAA==\"]Cg[\"AAAAAVIIAAAAARkAAAAByQIAAA==\",\"AAAAAVIIAAAAAAFAHgAA\"]Gk[\"AAAAAzsIAACTHgAA8B4AAAJLFAAAWxkAAAACcB0AABMfAAAAAdAUAAAAASodAAABgiAAAAGuGgAAAd8eAAAAAAAAAkQKAAAjHQAA\",\"AAABMAMAAAE7CAAA\"]Ek[\"AAAAATYIAAAAAg8AAACHEQAAAasCAAA=\",\"AAFmCwAAAAI2CAAAyx8AAAGCFQAAAAAAAAAAAAAAAAAAAAFzBgAA\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAc4LAAA=\",\"\"]Eg[\"AAAAAAAAAAAAAAAAAAAAAAAAA0MSAABKFgAAmR0AAA==\",\"AAFrCwAAAAAAAAAAAAAAAAAAAAAAAAFGCwAA\"]Bg[\"AAAAAAABQgMAAAFECQAA\",\"AAAAAescAAA=\"]Cc[\"AAAAAVwIAAAAAR4AAAAB1AIAAAFnAQAA\",\"AAAAAVwIAAA=\"]Fg[\"AAAAAAIqAwAAJxYAAAF3FgAAAAKpHAAAmR8AAAAAAAHdCgAAAAKzCAAA3RkAAAAAAAADLhYAAKMaAABrHgAA\",\"AAABKQMAAA==\"]Jk[\"AAABMAMAAAE6CAAAAVsZAAABiREAAAOvAgAAUh8AAM0fAAAAAAAAAAAAAAAAAAf/EQAAUhkAAE8eAACUHgAAAx8AACMfAABxHwAA\",\"AAFnCwAAAAE6CAAAAksUAACHFQAAAhQfAABNHwAAAAAAAAAAAAAAAAAAAUMKAAA=\"]Ec[\"AAAAAksDAACSCAAAAAABGwMAAAAAAAAAAAAAAAAAA40LAADVFwAAqxkAAA==\",\"AAGECwAAAAGSCAAA\"]Bc[\"AAAAAAA7MAAAAQAAdwABAO0XdwA=\",\"\"]Cc[\"\",\"AAAAAAAAAAADIAMAAGENAACcGQAAAAACVx0AAIwfAAA=\"]Gk[\"AAAAAS8IAAAAAg0AAACBEQAAAaECAAAAAAAAAAAAAAAAAATsCgAARA0AAGwQAABGEQAA\",\"AAFiCwAAAAEvCAAAAnkVAACpHgAAAUoRAAAAAAAAAbIbAAA=\"]Bc[\"\",\"AAAAAAA7MAAAAQAAdwABAO0XdwA=\"]Dg[\"AAAAAX4IAAAAAAH6AgAAAAAAAAAAAAAAAAABPQkAAA==\",\"AAAAAX4IAAAB4RUAAA==\"]Bg[\"AAAAAWkUAAA=\",\"AAAAAUwWAAABpxgAAA==\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAVYNAAA=\",\"\"]Fg[\"AAAAA7QNAADYFwAAhxkAAAAAA6MGAACTCAAAxQgAAAWYBgAAsQYAAK0NAADkEQAAuRQAAANhHQAAah0AAOkeAAABOQMAAA==\",\"\"]Dc[\"AAAAAUANAAAAAeIMAAAAAAAAAAAAAAAAAAACpwYAAO0IAAA=\",\"AAAAAAHXCAAA\"]Eg[\"AAAAAWIIAAAAAiQAAACgEQAAAdoCAAA=\",\"AAAAAWIIAAABrxUAAAABPB8AAAAAAAAAAAAAAAAAAQULAAA=\"]Gc[\"AAAAAXAIAAAAAawRAAAB7AIAAAAAAAAAAAAAAAAAOjAAAAEAAAAAAAgAEAAAAFAAkAa4BlILbxCvHKIepR4pHw==\",\"AAAAAXAIAAABxRUAAA==\"]Gc[\"AAAAAW8IAAAAAasRAAAB6wIAAAAAAAAAAAAAAAAAOjAAAAEAAAAAAAgAEAAAAHMGlgifCHENlhBYFIEW9hheIA==\",\"AAAAAW8IAAABwhUAAA==\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAe0KAAA=\",\"\"]Eg[\"AAAAAT0IAAAAAhEAAACLEQAAAbECAAA=\",\"AAAAAT0IAAABixUAAAAAAAAAAAAAAAAAAAACixgAAIwYAAA=\"]Hk[\"AAAABOEIAAAQCwAAjg0AAIUZAAABUg0AAAADogYAAMAGAADECAAAArAGAAC4FAAAAnUGAABpHQAAAAAAAAAAAAAABeIIAAARCwAAUw0AAI8NAACGGQAA\",\"AAI6AwAAbwsAAA==\"]Gc[\"AAAAAAABgxEAAAGkAgAAAAAAAAAAAAAAAAA6MAAAAQAAAAAABwAQAAAARgCIBkgLNQ2pEDkSRhniHA==\",\"AAAAAAF8FQAAAAAB0BwAAAGtGQAA\"]Gk[\"AAAAAlcIAACAFgAAAAABzwIAAAKYBgAAmgYAAAAAAZkGAAAAAAAAAAAABrkIAADaCAAAPQ0AAFQRAAAXFQAA1RkAAA==\",\"AAAAAzsDAABXCAAAgBYAAA==\"]AHk[\"AAAAAVMIAAAAAALKAgAAywIAAAAAAAAAAAAAAAAAAbkLAAA=\",\"AAFBGwAAAAFTCAAAAUEDAAAGlgYAAKAGAAC+BgAAwggAAJgNAABuGwAAOjAAAAEAAAAAABMAEAAAAKEGogajBqQGpgauBr8GwAbBBpMIlQjDCMQIxQjGCMgIwhC2FGYbYBw6MAAAAQAAAAAAEgAQAAAAmAaaBq8GsAaxBrIGtAatDa8N5BHmEbcUuBS5FLoUvBQfGWcbbh06MAAAAQAAAAAADgAQAAAAdAZ1BnYG5xGiFGEdYx1nHWgdaR1qHWsdbx3pHuseA6UGAACUCAAAxwgAAAaZBgAAswYAAK4NAADlEQAAuxQAAEobAAADYh0AAGwdAADqHgAAAT0DAAA=\"]Go[\"AAAAA1AIAACcEAAAYBQAAAGKDQAAAZYRAAABxwIAAA==\",\"AAFuCwAAAAVQCAAA3wgAAOAIAADhCAAA5AgAAAGaFQAAAAAAAAAAAAAAAAAAAALcCAAA4ggAAA==\"]Cg[\"AAAAAYcIAAACYhwAACgdAAABLwAAAAERAwAA\",\"AAAAAYcIAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAu4KAAD1EQAA\",\"\"]Hk[\"AAAAAAABaBEAAAF8AgAAAAAAAAAAAAAAAAAGowgAAPcKAADbCwAAZQ0AAGwYAADuGAAA\",\"AAAAATgcAAAAA1ccAAA1HgAA3R4AAAGLHwAAAZIeAAABNB4AAAAAAhwfAACHHwAA\"]Gc[\"AAAAAXQIAAABaBQAAAIpAAAAsBEAAAHwAgAAAXMfAAAGCRkAAAoZAAALGQAADBkAAIEfAACuHwAAAAGTHwAA\",\"AAAAAXQIAAACzRUAACMeAAA=\"]Ik[\"AAAAAUIIAAAAAY4RAAACtwIAACIfAAAAAAAAAAAAAAAAADowAAABAAAAAAANABAAAABIAD8K6gx0EI0Q/hGjFBQWUhx0HZMdjx4sH2If\",\"AAAAAUIIAAABkBUAAAADfw0AAGoYAAD2HAAAAAEoFgAA\"]Ac[\"\",\"AAABpRsAAA==\"]Dg[\"AAAAAAAAAAGQCwAAAAAAAAAAAaYUAAAAAoIXAACDFwAAAAF0BAAA\",\"AAAAAUgDAAA=\"]Fc[\"AAAAAogIAACEFgAAARofAAACMAAAAMQRAAABEgMAAA==\",\"AAF/CwAAAAGICAAAAQQWAAAAAAAAAAAAAAAAAAAAAa0YAAA=\"]Gg[\"AAAAASQIAAAAAXgRAAAClQIAAEoeAAAAAAAAAAAAAAAAAAdDAAAAhgYAAFsNAAB3DQAA3REAADsZAADhHAAA\",\"AAAAASQIAAABZBUAAAAAAXIdAAA=\"]Jc[\"AAAAAVYIAAAAAhsAAABAAwAAAc4CAAAAAAAAAAAAAAAAAAX4CAAAiBkAAHMdAAC/HgAAxh8AAA==\",\"AAQkAwAA9AwAAHgQAAAgHgAAAARWCAAA1xcAANgXAADbFwAAAAAB2RcAAAAAAAAAAAAAAAAAAdoXAAA=\"]AFc[\"AAAAADowAAABAAAAAAB3ABAAAAAkFScVKBUqFSwVLhUwFTIVNRU3FTkVOxU8FT4VQBVCFUQVRxVJFUoVTRVOFVEVUhVUFVYVWBVbFV0VXxVhFWIVZBVmFWkVaxVsFW4VcRVzFXQVdxV5FXoVfBV/FYAVghWFFYcViBWLFY0VjxWQFZIVlBWWFZkVmhWdFZ4VoRWjFaQVphWpFasVrBWvFbAVshW1FbYVuBW7FbwVvxXBFcIVxRXGFckVyxXNFc4V0BXTFdQV1hXZFdsV3BXeFeEV4hXkFeYV6BXqFewV7xXxFfIV9RX3FfkV+xX8Ff4VABYCFgQWBxYJFgsWDRYPFhEWEhY=\",\"\"]Jg[\"AAAAATgIAAABDR8AAAGIEQAAAa0CAAAAAAAAAAAAAAAAADowAAABAAAAAAAYABAAAABHAAgLpAvpDJwNnQ19EIAQphC4EMcQOhJ0FMIUERViFmcWbxbcF/wYTBmXGboZrBqmGw==\",\"AAAAATgIAAAChRUAAEwcAAA=\"]Do[\"AAAAATkIAAAAAAGuAgAAAAAAAAAAAAAAAUkNAAABqhgAAALSAwAAhRYAAA==\",\"AAAAATkIAAA=\"]Bc[\"AAAAAAAAAAABQBkAAAAAAUAfAAA=\",\"\"]Gk[\"AAAAAXoIAAAAAbYRAAAB9gIAAAAEDhkAAA8ZAAAQGQAAERkAAAABlB8AAAAAAAAAAAAEzQMAAM8LAAC1EAAAcBQAAA==\",\"AAF+CwAAAAF6CAAAAdkVAAA=\"]Kc[\"AAAAAbgaAAAAAAH/AgAAAAAAAAAAAAAAAAA6MAAAAQAAAAAAHQAQAAAAVACTBgAIXQrnCk0LOw16DZUQrhDAEE8R0xH9EVYUahZyFswXzxd0GCgZKxk+GcUZRRtaG6wbmB0yHlUg\",\"AAAAAAHqFQAAAAAAAAAAAAAAAAAAAAFxBgAA\"]Eo[\"AAAAAAAAAWoCAAAAAAAAAAAAAAAAAAQ6AAAAgQYAANgRAADdHAAA\",\"AAAAAAEwFQAAAAAAAAAAAAAAAAAAAAHHBgAA\"]Hk[\"AAAAAAAAA7UNAADZFwAAixkAAAAAA6UGAACUCAAAxwgAAAWZBgAAswYAAK4NAADlEQAAuxQAAANiHQAAbB0AAOoeAAAAAAAAAAGiCAAAAmQNAADaFwAA\",\"AAAAAAABQAMAAA==\"]Fg[\"AAAAAUEIAAAAAY0RAAABtgIAAAAAAAAAAAAAAAAABG4UAAC+FAAARRYAAHgWAAA=\",\"AAAAAUEIAAABjxUAAAACLB4AAC0eAAA=\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAaILAAA=\",\"\"]Co[\"AAAAATcIAAAAAhAAAABeAgAAAqwCAABNFgAA\",\"AAAAATcIAAAAASYZAAA=\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAbgLAAA=\",\"\"]Fg[\"AAAAAT4IAAACTgMAAAgaAAACEgAAAIwRAAABsgIAAAAAAAAAAAAAAAAAAvsRAACMGAAA\",\"AAFoCwAAAAIfAwAAPggAAAGNFQAA\"]Fk[\"AAAAAmgIAAAcGQAAAtAIAAAbGQAAAlsbAABbHAAAA+ECAADvCgAAsxsAAAAAAAAAAAAAAAAAAloNAACXDQAA\",\"AAF4CwAAAAFoCAAA\"]Fo[\"AAAAAnYIAAAsFgAAAAGyEQAAAfICAAAAAAAAAAAAAAAAAAXIFAAAGRYAAMMaAABOGwAAzBwAAA==\",\"AAAAAXYIAAAB0BUAAAAAAXkgAAA=\"]Ek[\"AAAAAoMIAADNHAAAAAIrAAAAwBEAAAENAwAA\",\"AAAAAYMIAAAB/BUAAAFsHgAAAAAAAAAAAAAAAAAAATIZAAA=\"]Eg[\"AAAAAUkIAAACrA0AACgdAAACFgAAAJQRAAACwgIAANMcAAA=\",\"AAAAAkkIAABiDQAAAZYVAAABtB8AAA==\"]Mo[\"AAAAAAABZhEAAAF6AgAAAAAAAAAAAAAAAAA6MAAAAQAAAAAACwAQAAAAvAjcCPYKDQtPC9oLTA2BDYcYLRnJHAkd\",\"AAAAAbocAAAAAAEgHwAABlEWAACVGAAANxkAANgcAABQHQAAvR8AAARSFgAArxkAANscAACKHwAAAVMWAAACWBwAANkcAAADUBYAAFIbAACEIAAAAAAAAk8dAABQHwAA\"]Eo[\"AAAAAZAIAAAAAcoRAAABGQMAAAAAAAAAAAAAAAAABVcAAACzCwAALQ0AAMEUAABYFgAA\",\"AAAAAZAIAAABERYAAA==\"]Dc[\"AAAAAAAAAAAAAAAAAAAAAAAABqYIAAD4CgAA3wsAAGYNAABtGAAA7xgAAA==\",\"\"]Fg[\"AAAAAV4IAAAAAyAAAACcEQAAdhYAAAHWAgAAAAEmHwAA\",\"AAF0CwAAAAFeCAAAAlsLAACmFQAAAAAAAAAAAAAAAAAAAAFcCwAA\"]Ok[\"AAAABVsIAABnDQAAZBQAAF4WAABuGAAABQgIAABbCwAA6BcAAPAYAADcGQAABacIAABgCwAAmhEAAHYWAADMHwAABtMCAACXBgAA8woAAJkNAAC6EAAAaxgAAAVnAQAA4QsAAK8QAADoEQAAEh8AAAEmHwAAAAAAAAAAAAAAB6gIAAD5CgAAXAsAAJMLAADgCwAA8RgAAFAZAAA=\",\"AAI/AwAAcwsAAAABWwgAAAGjFQAAAc0IAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAlARAABsFgAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAoYNAABREQAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAogNAABTEQAA\",\"\"]Bk[\"AAAAAAG3BgAAAAAAAAAAAAAAAAAAAAGkDQAA\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAZUNAAA=\",\"\"]Lg[\"AAABcxwAAANxCAAAKxYAAIoeAAAAAa0RAAAB7QIAAAAAAAAAAAAAAAAAAqkIAAB2HQAA\",\"AAABcxwAAAJxCAAAih4AAAHGFQAAAAFYHwAAAAKBHwAArh8AADowAAABAAAAAAAIABAAAAD1HGAddx54HnkemR57IIwgliABkx8AAAHyHAAAAAAAAAAAAfwKAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAuMIAABzDQAA\",\"\"]Eg[\"AAAAARQIAAAAAV8RAAABdAIAAAAAAAAAAAAAAAAAAz4JAAB+FwAAgBcAAA==\",\"AAAAARQIAAAAAAFRIAAA\"]Fg[\"AAAAAAG4DQAAAAAAAAJ2BAAAlQsAAAAAAAAAAAAAOjAAAAEAAAAAAAsAEAAAADUAJgP0CiUWPhbWF4IYpRlLHMgcPR0+HQ==\",\"\"]Ck[\"AAAAAAABaREAAAF9AgAA\",\"AAAAAAAAAAAAAAAAAAAAAAAAAU8LAAA=\"]Cg[\"AAAAAAF5BAAAAAAAAAAAAAAAAAAAAAPmCAAAFwsAAGMNAAA=\",\"\"]Fo[\"AAAAAR8IAAAAAXMRAAABkAIAAAAAAAAAAAAAAAAABugMAAA0EAAA2xEAALAUAAB1FgAAwhcAAA==\",\"AAAAAR8IAAABWxUAAAAAAAFdIAAA\"]Og[\"AAACMR8AAM4fAAACiggAAA0aAAAAAsYRAADTHwAAAhQDAAC7HwAAAAVnHQAAaB0AAGkdAABqHQAAax0AAAAAAWwdAAAAAAAAAAAGrRgAAK0aAAAvHAAAUBwAALIcAAAuHQAA\",\"AAGACwAAAjEfAADOHwAABooIAACDGQAAhBkAAIUZAACHGQAAiRkAAAEJFgAAAbkfAAACixkAALsfAAAAAAAAAAAAAAAAAAKGGQAAihkAAA==\"]Fg[\"AAAAASIIAAAAAXYRAAABkwIAAAAAAAAAAAAAAAAABUEAAACEBgAAihAAANwRAACZGgAA\",\"AAAAASIIAAABYRUAAAAAAfUeAAA=\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAe0cAAA=\",\"\"]Ec[\"AAAAAAABuxEAAAEFAwAAAAAAAAAAAAAAAAAHlQYAAAMdAAA/HQAAQh0AAG8eAACrHgAA1R4AAA==\",\"\"]Dk[\"AAAAAmUUAAB4GAAAAAACtAIAALUCAAA=\",\"AAAAAAAAAAAAAAAAAAAAAAFJDQAAAaoYAAA=\"]Cg[\"AAAAATwIAAAAAYoRAAABsAIAAA==\",\"AAAAATwIAAABiBUAAA==\"]Hc[\"AAAAAREIAAAAAV0RAAABbgIAAAAAAAAAAAAAAAAABu4IAADFFwAA7BcAAJEYAACYGAAAmxoAAA==\",\"AAAAAREIAAACNxUAAA0fAAAAAAAAAAAAAAAAAAAAAegIAAA=\"]Fo[\"AAABdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATEZAAAAAegcAAA=\",\"AAABdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATEZAAAAAegcAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAhULAADyGAAA\",\"\"]Co[\"AAAAAAAAAfsCAAAAAAAAAAAAAAAAAAKqCAAAdx0AAA==\",\"AAAAAAHiFQAA\"]Cg[\"\",\"AAAAADswAAABAAB6AAEA1R96AAAAOzAAAAEAAHoAAQDGGnoA\"]Ac[\"AAAAAY0eAAA=\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAp8LAACwGQAA\",\"\"]Lk[\"AAAABOAIAAAPCwAAjQ0AAIQZAAABUQ0AAAFAAwAABqEGAAC/BgAAwwgAALUNAADZFwAAixkAAAOvBgAAtxQAAGcbAAADdAYAAGgdAABvHQAAA6UGAACUCAAAxwgAAAWZBgAAswYAAK4NAADlEQAAuxQAAANiHQAAbB0AAOoeAAA=\",\"AAAAAAABQAMAAAAAAAHFHgAA\"]Eo[\"AAAAAXsIAAAAAbcRAAAB9wIAAAAAAAF8IAAAAAAAAAAAAAJwBgAAWRwAAA==\",\"AAAAAXsIAAAB2xUAAAAAAAFSHQAA\"]Eg[\"AAAAAAABbhEAAAGIAgAAAAAAAAAAAAAAAAAD4gsAAJkYAAAkHQAA\",\"AAAAAAAAAAAAAAAAAAABUR8AAA==\"]Fk[\"AAAAAYUIAAAAAi0AAADCEQAAAQ8DAAAAAAAAAAAAAAAAAAWuCAAASwoAANELAACYGQAAzxkAAA==\",\"AAAAAYUIAAACABYAAFcgAAA=\"]Dc[\"AAAAAAHRCAAAAALiAgAA6xgAAAAAAAAAAAAAAAAAAjwNAAAdGQAA\",\"AAF5CwAA\"]Hk[\"AAAAAXUIAAABQxkAAAGxEQAAAfECAAAAAAb/CgAAAAsAAAELAAACCwAA9RwAAGAdAAAAAfIcAAAAAAAAAAAEsggAANMIAABkFgAAOR0AAA==\",\"AAF9CwAAAAF1CAAAAc4VAAA=\"]Fg[\"AAAAAV8IAAAAAiEAAACdEQAAAdcCAAAAAAAAAAAAAAAAAAXUAwAAnggAAK0IAABwDQAAuRAAAA==\",\"AAAAAV8IAAABqRUAAA==\"]Fk[\"AAAAAAEiHQAAA70GAADZCAAAPxkAAAAAAAAAAAAAAAAAAAZGCwAAMBIAAAkVAAAKFQAACxUAAJ8bAAA=\",\"AAAAAAAAAAAAATcDAAA=\"]Go[\"AAAAAQ8IAAAAAVsRAAABbAIAAAAAAAAAAAAAAAAAOjAAAAEAAAAAAAkAEAAAADsABguWC8cL5AyiELsQxxNgFvsY\",\"AAAAAQ8IAAABMhUAAAAAAAABbBwAAA==\"]Hc[\"AAABMB8AAAGMCAAAAAHIEQAAARYDAAAAAAABvh8AAAAAAAAAAAAEAh0AAAgfAAAJHwAAZCAAAA==\",\"AAGCCwAAATAfAAABjAgAAAENFgAAAbofAAAAAAAAAb4fAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAsAIAAATCwAA\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAbwLAAA=\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAjwJAACSDQAA\",\"\"]Do[\"AAAAAAAAAbMCAAAAAAAAAAAAAAAAAAGoGwAA\",\"AAAAAAAAAAAAAAAAAAAAAAFJDQAAAaoYAAA=\"]Co[\"AAAAAAAAAAAAAAAAAAAAAAFJDQAAAaoYAAACPgoAAEgWAAA=\",\"AAEyAwAA\"]Gk[\"AAAAAgcIAABNCAAAAAM7EwAAJRkAAE8ZAAAAAAACOQMAAMUeAAAAAAE9AwAAAAAAAAACSBwAAAcdAAA=\",\"AAI4AwAAbAsAAAFhGQAAAU0IAAABVB0AAA==\"]Ac[\"AAAAAAF3BAAA\",\"\"]Lo[\"AAAAAkAIAACxGgAAAAFCHgAAA2oYAAAtHgAAQR4AAAZHAwAASQoAAKEYAADHHAAAZh0AAEYfAAAFIAMAADUDAAA2AwAAKBYAAJ8eAAAENwMAANIeAADhHgAAUx8AAAKoBgAAjh8AAAJJGwAAnh4AAAAAAAAAAAJFCgAAMBYAAA==\",\"AAAAAUAIAAABMwMAAAABvxoAAA==\"]Do[\"AAAAAUgIAAABMwMAAAABvAIAAAAAAAAAAAAAAAAAA3UQAACyFAAAwxcAAA==\",\"AAAAAUgIAAA=\"]Fg[\"AAAAAXcIAAAAAbMRAAAB8wIAAAAAAAAAAAAAAAAABfgRAADJFAAApBkAAMQaAABPGwAA\",\"AAAAAXcIAAAB0xUAAAAAAW4gAAA=\"]Gk[\"AAAAAqgEAABDCAAAAAGPEQAAAbgCAAAAAAAAAAAAAAAAADowAAABAAAAAAAKABAAAABJAIkG/QdJCzYNqhBVFMkXxBnjHDkf\",\"AAAAAUMIAAABkhUAAA==\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAlUNAACRDQAA\",\"\"]Ec[\"AAAAASAIAAAAAXQRAAABkQIAAAAAAAAAAAAAAAAAAqYYAABcGQAA\",\"AAAAASAIAAABXRUAAAFvIAAA\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAdgLAAA=\",\"\"]Fc[\"AAAAAWYIAAABaRsAAAImAAAAoxEAAALfAgAAIR8AAA==\",\"AAAAAWYIAAABshUAAAAAAAAAAAAAAAAAAAACfAYAAH4GAAA=\"]Go[\"AAAAAVsUAAAAA4YQAACaGQAAmxkAAAACGxUAAJseAAADdxAAAOMRAADrFwAAASMVAAAAAAGyGgAAAe8IAAAAAAAAAkcKAABuEAAAAAHHGQAA\",\"AAAAAUsDAAA=\"]DAg[\"AAAAADswAAABAAD1AFwAiBYAAIsWAQCPFgAAkRYAAJMWAQCXFgAAmRYAAJsWAQCfFgEAoxYAAKUWAQCoFgAAqhYAAK0WAACvFgEAsxYBALYWAAC4FgAAuhYAALwWAAC+FgAAwRYBAMQWAADGFgAAyRYAAMsWAQDOFgAA0RYBANQWAADXFgEA2xYBAN8WAADhFgEA5BYAAOcWAADpFgAA6xYBAO8WAQDyFgAA9BYAAPcWAAD5FgEA/RYAAP8WAAABFwEABRcBAAkXAQAMFwAADhcAABAXAAATFwEAFhcAABgXAAAaFwAAHBcAAB8XAQAiFwAAJBcAACYXAAAoFwAAKxcBAC4XAAAxFwEANRcAADcXAAA5FwEAPRcBAEAXAABDFwAARRcAAEcXAQBLFwAATRcBAFEXAQBUFwAAVhcAAFgXAABaFwAAXBcAAF8XAABhFwAAYxcBAGcXAABpFwAAaxcBAG4XAABxFwEAdRcBAHgXAAB7FwAAfRcAANUfegA7MAAAAQAA9QBbAGIKegCJFgEAjRYBAJAWAACSFgAAlRYBAJgWAACaFgAAnRYBAKEWAQCkFgAApxYAAKkWAACrFgEArhYAALEWAQC1FgAAtxYAALkWAAC7FgAAvRYAAL8WAQDDFgAAxRYAAMcWAQDKFgAAzRYAAM8WAQDTFgAA1RYBANkWAQDdFgEA4BYAAOMWAADlFgEA6BYAAOoWAADtFgEA8RYAAPMWAAD1FgEA+BYAAPsWAQD+FgAAABcAAAMXAQAHFwEACxcAAA0XAAAPFwAAERcBABUXAAAXFwAAGRcAABsXAAAdFwEAIRcAACMXAAAlFwAAJxcAACkXAQAtFwAALxcBADMXAQA2FwAAOBcAADsXAQA/FwAAQRcBAEQXAABGFwAASRcBAEwXAABPFwEAUxcAAFUXAABXFwAAWRcAAFsXAABdFwEAYBcAAGIXAABlFwEAaBcAAGoXAABtFwAAbxcBAHMXAQB3FwAAeRcBAHwXAAAAOzAAAAEAAPUAAgDLEHoAxhp6AA==\",\"\"]Fk[\"AAAAAAAAAqgCAACpAgAA\",\"AAAAAAAB0x4AAAA6MAAAAQAAAAAACAAQAAAAYQuOC4cQ6hcNGbEbMhymHegeAAOuFAAAnRgAAGMbAAA=\"]Hg[\"AAAAAUMcAAAAAdMeAAAAOjAAAAEAAAAAAAgAEAAAAGELjguHEOoXDRmxGzIcph3oHgADrhQAAJ0YAABjGwAAAAAAAAAAAAADXRkAAJUZAACvGwAA\",\"AAIuAwAAZAsAAA==\"]Eo[\"AAAAAAABxBwAAAGPCwAAAQIZAAAAAAIqHQAAlx8AAAGCIAAAAa4aAAAB3x4AAAADxRAAAKUUAAC8GQAAAYIXAAA=\",\"\"]AOc[\"AAAAAAAGpRgAAPQYAAD5GAAAaRwAAJsdAACzHwAAOjAAAAEAAAAAABUAEAAAAHsN0RT1GJQZoBmuGaQabBtqHEceSh5NHm0eiB6MHqgewx4tHy4fhB9RII8gOjAAAAEAAAAAABMAEAAAAGgcbRytHNAc/hz/HHIdSR5QHoke9R74Hg4fDx9PH10fXh9fH50faiA6MAAAAQAAAAAAEwAQAAAA+BitGa8ZDhoFHTQeRh5MHlEe0B70HvkeRx9gH3Ufih9dIIcgoiCwIAcIGQAAYhsAAGwcAAD6HAAASB4AAK0fAAB4IAAABwoaAABYHAAA2RwAAEseAAChHgAAvh8AAKggAAA6MAAAAQAAAAAADgAQAAAAUBYZGdEZ2hlSG7UbTh6qHhsfHB8/H1ofhx+4H4QgA6saAAAAHQAAmyAAAAGJHwAABaoaAACgHgAAQh8AAFEfAAC1IAAABJEZAADxHAAATx0AAFAfAAAAAf0cAAA=\",\"\"]Ek[\"AAAAASgIAAAAAXwRAAABmgIAAAAAAAAAAAAAAAAAA7AIAAC2CwAAbxwAAA==\",\"AAAAASgIAAACbhUAAG4eAAA=\"]Eo[\"AAAAA8cXAACpGQAAKx0AAALqHAAAUh4AAAFtHQAAAXEeAAAAAAAAAAAAAAAAAAKqGQAAoBsAAA==\",\"AAABLwMAAA==\"]Ec[\"AAAAAAAAAZgCAAAAAAAAARkZAAAAAAAAAAAFzAMAAJwLAAC0EAAAVxQAAG8UAAA=\",\"AAAAAAFrFQAA\"]Mk[\"AAAAAXMIAAAAAa8RAAAB7wIAAAFzHwAABwkZAAAKGQAACxkAAAwZAAB0HwAAgR8AAK4fAAA7MAAAAQAADQAGAIIQAwAlHgMAdx4CAJkeAACMIAAAliAAAAI9HwAAkx8AAAAAAAAAAAA6MAAAAQAAAAAACAAQAAAAsQhXClwK4wvJEMAUfxaZGTgd\",\"AAF8CwAAAYEYAAABcwgAAAHLFQAA\"]Gg[\"AAABSxEAAAGNCAAAAAIzAAAAyREAAAMXAwAAiB4AAIQfAAAAAAAAAAAAAAAAAAG0HAAA\",\"AAGDCwAAAUsRAAACjQgAAIceAAABDxYAAAABiB4AAA==\"]Ok[\"AAAAAAAEZhgAABMZAADNGQAAeh4AAAFkHQAAOjAAAAEAAAAAAAwAEAAAAF0NUBRAFiEZpRpwG1Mcthy7HDQdnh1yHq8fOjAAAAEAAAAAAAcAEAAAAEwUCRkOGcoe4h5UH1kfjx86MAAAAQAAAAAABwAQAAAANwP/CoIQXRszHL8cJR5IHzowAAABAAAAAAAIABAAAAAEGp0aORw9HEMdfh6VHsweWiAAAAIVHwAAaSAAAAGpIAAA\",\"\"]Gg[\"AAAAARoIAAAAAWcRAAABewIAAAAAAAAAAAAAAAAAOjAAAAEAAAAAAAgAEAAAAD0A/AfpCD8JMQ1NDWgNcxTeHA==\",\"AAAAA6YEAACoBAAAGggAAA==\"]Ec[\"AAAAAS0IAAAAAQsAAAABnwIAAAAAAAAAAAAAAAAAAp0LAAA1FgAA\",\"AAAAAS0IAAAAAAAAAAGaGAAA\"]Go[\"AAAAAAAAAX4CAAABrRwAAAGKHwAAAAFYHAAAAYQgAAAAAAAAAAA6MAAAAQAAAAAADAAQAAAAPgDqCEAJpxDMEdkR6RH2EcYUhBe0G8oc3xw=\",\"AAAAAAFAFQAA\"]Dc[\"AAAAAUsIAAAAAAHDAgAAAAAAAAAAAAAAAAACxhMAAOMXAAA=\",\"AAAAAUsIAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAqALAACxGQAA\",\"\"]A@g[\"AAAAAAAGQwMAAIYQAACaGQAAmxkAAL8ZAADFHAAAApELAAChIAAABBsVAAADGQAAphkAAMMZAAAGNgMAAHcQAADjEQAA6xcAAFwbAAA5HgAABjcDAACoFAAAIxUAAJoYAAAgGQAAmh0AAAGyGwAAAz8WAABAHwAAfR8AAAGyGgAAAe8IAAAAA8YQAACnFAAAvRkAAAKDFwAAWB0AAAABdQQAAAABxxkAAAAAAYUgAAA=\",\"AAAAAdULAAABSQMAAA==\"]Ag[\"\",\"AAAAAAAByBAAAA==\"]Ec[\"AAAAArsGAADWCAAAAAHKGQAAAAAAAAAAAAAAAAAABHMQAAA/EgAAYRQAANAXAAA=\",\"AAAAAAEqAwAA\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAasNAAA=\",\"\"]Hc[\"AAAAAmcIAACSEAAAAz0KAACuHAAAoB8AAAGkEQAAAeACAAAAAAAAAAAAAAAAAAJ+BgAA7hwAAA==\",\"AAF3CwAAAAJnCAAA+xwAAAG1FQAAAh4fAAC3HwAAASEfAAA=\"]Fc[\"AAAAARcIAAAAAWIRAAABdwIAAAAAAAAAAAAAAAAABUMKAADPFAAAOhYAAFEZAAAMGgAA\",\"AAAAARcIAAAAAAAAAdAeAAA=\"]Lg[\"AAAAAjsDAACGCAAAAAIuAAAAwxEAAAEQAwAAAAAAAAAAAAAAAAA6MAAAAQAAAAAABwAQAAAA1gNtEEgR+REyGcUaURvOHA==\",\"AAAAAYYIAAACbRQAAAIWAAAAAdYeAAABsBoAAAAAAAGZIAAAAoIeAACDHgAAAAAAAAAFUBEAAFERAABSEQAAUxEAAFQRAAA=\"]Cg[\"AAAAAAABvhEAAAEIAwAAAAAAAAAAAAAAAAACWAoAAN4KAAA=\",\"\"]Dc[\"AAAAAAAAAAABSg0AAAEfHgAAAAAAAAAAAAABQQoAAA==\",\"AAJMAwAAhQsAAA==\"]Bk[\"\",\"AAAAAAVWGQAAVxkAAFgZAABZGQAAWhkAAA==\"]Eg[\"AAAAARUIAAAAAWARAAABdQIAAAAAAAAAAAAAAAAABS4NAAA0EgAAOhMAABAVAACBFwAA\",\"AAAAARUIAAA=\"]Dc[\"AAAAAWMIAAAAAaERAAAB2wIAAA==\",\"AAAAAWMIAAAAAdEcAAAAAAABSB8AAA==\"]Hg[\"AAABoh8AAAFkCAAAAeweAAABohEAAAHcAgAAAAABeCAAAAAAAAAAAAAAA4wLAACDGAAAqxwAAA==\",\"AAF1CwAAAaIfAAACZAgAAKIcAAABsBUAAAF2IAAAAAAAAXggAAA=\"]Dg[\"AAAAAQkIAAAAAVURAAAAAAAAAAAAAAAAAAADTgsAALcLAAB8DQAA\",\"AAAAAQkIAAA=\"]Dg[\"AAAAAV0IAAAAAh8AAACbEQAAAdUCAAA=\",\"AAAAAV0IAAACpBUAAKQcAAAAAAGIHwAA\"]Gc[\"AAAAASUIAAAAAXkRAAABlgIAAAGdHwAAAAAAAAAAAAAAAAasBgAAmwsAAMEQAADeEQAAvxQAAE4WAAA=\",\"AAAAASUIAAABZhUAAAAAAV4fAAA=\"]Bk[\"AAAAAAAAAacCAAA=\",\"AAAAAAAAAAAAAa4UAAA=\"]Ak[\"\",\"AAAAAAAAAAABEhkAAA==\"]AFg[\"AAAAAAHwDAAABUgKAABpGAAAFhkAAH0eAABEHwAAAYAfAAA6MAAAAQAAAAAADQAQAAAAYA1TFEMWJBnDGagacxtWHLkcvhw3HaEddR6yHzowAAABAAAAAAAJABAAAABPFAwZERmTGTEc5R5XH4Efkh+uHzowAAABAAAAAAAKABAAAAACC4UQthpgGzYcwhz1HGAdKB54HnkeOjAAAAEAAAAAAAkAEAAAAAcaoBo8HEAcRh04HoEemB7PHj0fAAMnHQAAgx4AAIUeAAABGB8AAAKsIAAArSAAAAAAAALhCgAAxQsAAAAAAAABMRkAAAAB6BwAAA==\",\"AAAAAUYDAAAAAAABHgMAAA==\"]Eo[\"AAABOQUAAAF/CAAAAAABAAMAAAAAAAAAAAAAAAAABZ8QAAChFAAAOBYAAEccAABKHwAA\",\"AAAAAX8IAAAB7BUAAA==\"]Gc[\"AAAAA1kIAADDHAAAhh4AAAADHAAAAJgRAADlFwAAAdECAAA=\",\"AAAAAVkIAAACXgsAAJ4VAAABTh0AAAAAAAAAAAAAAAAAAAJfCwAA2AsAAA==\"]Fk[\"AAABhwsAAAKIEAAAXxYAAAJ6BAAAIRYAAAAAAvUKAAD6GAAAAUMeAAAAAAAAAAAAAAADNgAAAF8CAAAKCwAA\",\"AAAAAAABQgMAAA==\"]Fk[\"AAAAAAABbB4AAAEKAwAAAAAAAAAAAAAAAAAHVgAAAFQLAAAsGQAAbxsAAKQeAACnHgAAKx8AAA==\",\"AAAAAAH3FQAAAAAAAAFHHQAA\"]Dg[\"AAAAAAAAAQsDAAAAAAAAAAAAAAAAAASYCAAAoQgAAFoUAACDFgAA\",\"AAAAAAH5FQAA\"]AEo[\"AAAAA0YDAAAZFQAACx0AAAHtDAAABGgYAAAVGQAA0xkAAHweAAAAOjAAAAEAAAAAAA0AEAAAAF8NUhRCFiMZwRmnGnIbVRy4HL0cNh2gHXQesR86MAAAAQAAAAAACAAQAAAAYQ1OFAsZEBmSGZwZ5B5WH5EfOjAAAAEAAAAAAAcAEAAAAAELhBC1Gl8bNRzBHEcdJx46MAAAAQAAAAAACQAQAAAAwxAGGp8aVhs7HD8cRR2AHpcezh4CVx0AAIwfAAACgh4AAIQeAAABFx8AAAKrIAAAtiAAAAAAAAXuDAAAsxQAADEWAADEFwAA4RcAAA==\",\"AAAAAQsdAAAAAAFFAwAA\"]Bc[\"AAAAAAAAAX8CAAA=\",\"AAAAAAFCFQAA\"]Do[\"AAAAAAAAAYACAAAAAAAB2RwAAAAAAAAAAAAEpAgAAFgLAADcCwAAfRYAAA==\",\"AAAAAAFEFQAA\"]Fg[\"AAAAASEIAAAAAXURAAABkgIAAAAAAAAAAAAAAAAABZoLAACdEAAAnxQAADQWAABZFgAA\",\"AAAAASEIAAABXxUAAAAAAAGHIAAA\"]Hg[\"AAAAAUoIAAAAAe4eAAABJhYAAAX6CgAARBYAAL4ZAADYGQAAdBsAAAACOQMAAMUeAAAAAAE9AwAAAAAAAAAD6wwAAMgTAAC+GgAA\",\"AAAAAUoIAAAAAAHUHAAAAAE2AwAA\"]Gk[\"AAAAAVUIAAAAAAHNAgAAAAAAAAAAAAAAAAA6MAAAAQAAAAAACAAQAAAAqga/CMkIEgtUDZANpw0WFdQZ\",\"AAFwCwAAAANVCAAAtA0AALYNAAAAAAG1DQAA\"]AMo[\"AAAAOjAAAAEAAAAAAB0AEAAAAL0I3wgOC4kNYxQcFV0W1xfnF4MZ2xm6GjgcQRygHKIcuhz7HCwdMx1xHZ0dmh7JHnkffB+CH6kfUiBfIDowAAABAAAAAAAOABAAAAD7Ck4NzBlIG2kbbRvPHFQdpB3sHhAfwh/DH8gfoyA6MAAAAQAAAAAAFAAQAAAAlgagBr4GwgjNCJgN/hjNGbkaaBtuG9Ec7xzwHPgcMh1CHuAe7h6bH3IgOjAAAAEAAAAAAAsAEAAAAK4GwhC2FE0ZvxpmG10cYBzUHFodnB2NHwQfGQAAbh0AAGogAACmIAAABDUDAADnEQAAohQAAGcdAAABSB8AAAFKGwAAAYUfAAAAAWkgAAAAAAAAOjAAAAEAAAAAABIAEAAAAL4IzghQC9ILyxOrFHkW/xgYGUIZUxnAGq4boxzVHFUdwR4KHzsf\",\"AAFtCwAAAAAAAAAAAAE5AwAA\"]Dc[\"AAAAAAAAAWgCAAAAAAAAAAAAAAAAAAM5AAAA8gwAAHQWAAA=\",\"AAAAAAEsFQAA\"]Ag[\"\",\"AAAAAAAB0hQAAA==\"]Kk[\"AAAC7x4AAIYgAAACiwgAAOwYAAAFrwgAAEwKAAByFAAARxYAAF4ZAAADMgAAAMcRAACzHwAAARUDAAAAAAAAAAAAAAABvB8AAA==\",\"AAOBCwAAAR0AAGggAAABtR8AAAGLCAAAAQsWAAAAAAGKIAAAAAAAAAAAAAAAAAPQCwAA0QsAAAIdAAA=\"]Cg[\"AAAAAAAAAAAAAAAAAAAAAAAABNMDAAA+DQAAdA0AAJMNAAA=\",\"\"]Dc[\"AAAAAAAAAYkCAAAAATQeAAAAAAAAAAAAAAACpQgAAIINAAA=\",\"AAAAAAFNFQAA\"]Eo[\"AAAAASkIAAAAAX0RAAABmwIAAAAAAAAAAAAAAAAABZoIAADdCAAAehgAAHwYAABJHAAA\",\"AAAAASkIAAABcRUAAA==\"]Fg[\"AAAAASoIAAAAAX4RAAABnAIAAAAAAAAAAAAAAAAAA3sYAAB9GAAAShwAAA==\",\"AAAAASoIAAABcxUAAAAAAcMZAAABBR0AAA==\"]Do[\"AAAAASsIAAAAAX8RAAABnQIAAAAAAAAAAAAAAAAAAaMZAAA=\",\"AAAAASsIAAABdBUAAAGbHQAA\"]Eo[\"AAAAAScIAAAAAXsRAAABmQIAAAAAAAAAAAAAAAAAA5wGAABKFAAAvRQAAA==\",\"AAAAAScIAAABbBUAAAABMB0AAA==\"]Dc[\"AAAAAAABvBEAAAEGAwAAAAAAAAAAAAAAAAAEcRAAABMVAACGFwAAlhgAAA==\",\"\"]Fk[\"AAAAAXIIAAAAAa4RAAAB7gIAAAAAAAAAAAAAAAAABNQIAABmFAAALx0AAEkdAAA=\",\"AAAAAXIIAAAByRUAAAJDHwAARB8AAAGnIAAA\"]Ek[\"AAAAARIIAAAAAV4RAAABbwIAAAAAAAAAAAAAAAAAAugIAAB2EAAA\",\"AAAAARIIAAABORUAAAAAAAABCBkAAA==\"]Fk[\"AAAAASkWAAAAAAAAAAAAAAAAAAAAAAAAAAAAATEZAAAAAegcAAA=\",\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMRkAAAAB6BwAAA==\"]ACg[\"AAAAAUYDAAAABGcYAAAUGQAAex4AAEMfAAACFxkAAH8fAAA6MAAAAQAAAAAADAAQAAAAXg1RFEEWIhmmGnEbVBy3HLwcNR2fHXMesB8HTRQAAAoZAAAPGQAA4x4AAFUfAABbHwAAkB8AADowAAABAAAAAAAIABAAAAAAC4MQShleGzQcThzAHCYedx46MAAAAQAAAAAACgAQAAAASRkFGp4aOhw+HEQdNx5/HpYezR58IAGdHgAAAV8dAAABFh8AAAGqIAAAAAAAA+wMAADgEQAAyRMAAA==\",\"AAAAAAABQwMAAA==\"]Ck[\"AAAAAAAAAYMCAAA=\",\"AAAAAAFKFQAAAAAAAAAAAAAAAAAAAAFZCwAA\"]Ec[\"AAAAAAAAAYICAAAAAAAAAAAAAAFPHQAAAAAFWQsAAN4LAADzEQAAfhYAANIZAAA=\",\"AAAAAAFJFQAA\"]Ec[\"AAAAAUwIAAADtgYAALcGAAD7CgAAAALEAgAAJhYAAAT6CgAAvhkAANgZAAB0GwAA\",\"AAAAAUwIAAA=\"]Co[\"AAAAAVgIAAACfAQAAH0EAAABlxEAAAHQAgAA\",\"AAAAAVgIAAABnRUAAA==\"]Dc[\"AAAAAAGSCwAAAkgKAACGCwAAAAEeAwAAAAAAAAAAAAAAAAGbGAAA\",\"AAErAwAA\"]Do[\"AAAAARsIAAAAAW8RAAABjAIAAAAAAAAAAAAAAAAAAvwKAACtFAAA\",\"AAAAARsIAAABUhUAAA==\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAnwGAADsHAAA\",\"\"]Eg[\"AAAAAAACFB8AAE0fAAADUh8AAKwfAADNHwAAAAL4GAAA0B4AAAJIHgAArR8AAAABPx8AAAAAAVEfAAA=\",\"\"]Cg[\"AAAAAAF9BAAAAAAAAAAAAAAAAAAAAAFBEgAA\",\"AAABPAMAAA==\"]Cg[\"AAAAAAABahEAAAGEAgAA\",\"AAAAAAAAAAAAAAAAAAAB2hwAAA==\"]Cg[\"AAAAAVEIAAAAARgAAAAByAIAAA==\",\"AAAAAVEIAAAAAYEgAAA=\"]Dg[\"AAAAAAAAAqIbAAAGHwAAAqMbAACUHQAAAANCEgAAlR0AADofAAACGhUAAAoaAAA=\",\"\"]Fo[\"AAAAATIIAAAAAYQRAAACpQIAAC0DAAAAAAAAAAAAAAAAAAQzFgAAfxgAAK0bAAArHgAA\",\"AAFjCwAAAAIyCAAAVBQAAAF/FQAAAT4fAAA=\"]Fc[\"AAAAATEIAAAAAYIRAAABowIAAAAAAAAAAAAAAAAABEUAAACHBgAAXRQAAIAYAAA=\",\"AAAAATEIAAABehUAAAAAAZwgAAA=\"]Ao[\"\",\"AAAAOzAAAAEAAHYAAQD7A3YA\"]Eo[\"AAAAAU4IAAAAAWAgAAABxQIAAAAAAAAAAAAAAAAABv4HAADrCAAAQQkAAF8UAAAiFgAAWhYAAA==\",\"AAAAAU4IAAA=\"]Ak[\"AAAAAAAAAsIaAADWHgAA\",\"\"]Ec[\"AAAAAh8DAAA/CAAAAk4DAAAJGgAAAAAAAAAAAAAAAAAAAAHeCAAA\",\"AAIxAwAAaQsAAAABPwgAAA==\"]Ak[\"AAAAAAG2BgAAAWgbAAA=\",\"\"]Dc[\"AAAAAAF8BAAAA/ccAAD4HAAA+RwAAAAAAAAAAAE9AwAA\",\"AAAAAAAB/hgAAA==\"]ADg[\"AAAAAAA6MAAAAQAAAAAACgAQAAAAVxw1Htwe3R4eH0wftx+5H3UgdiCeIDowAAABAAAAAAAIABAAAADGHCweIB8hH0Ufix+7H1YgWCA6MAAAAQAAAAAADQAQAAAAURaVGDcZ2BxQHT0ePh6SHh0fHx+IH70fwB+TIAdSFgAArxkAANscAAA0HgAANh4AANEeAACKHwAAA1MWAAAkHwAAeCAAAANYHAAA2RwAAL4fAAAGUBYAAFIbAAAcHwAAhx8AALgfAACEIAAAAAGJHwAAAtocAABRHwAABPEcAABPHQAAUB8AAL8fAAABvB8AAA==\",\"\"]Gc[\"AAAAASwIAAAAAYARAAABngIAAAAAAAAAAAAAAAAAAlYKAACmDQAA\",\"AAAAASwIAAABdxUAAAAAAAAAAAFaHwAAAAAAAAAAAAAAAAABhSAAAA==\"]Ec[\"AAAAAAABvREAAAEHAwAAAAAAAAAAAAAAAAAHXQsAAJ8NAAByEAAAFBUAAIcXAACXGAAAQB0AAA==\",\"\"]Dc[\"AAAAAAAAAYoCAAAAAAAAARwfAAAAAAAAAAACgw0AAEUOAAA=\",\"AAAAAAFOFQAA\"]Ek[\"AAAAARkIAAAAAWQRAAABeQIAAAAAAAAAAAAAAAAABDANAADrEQAABh0AAKweAAA=\",\"AAAAARkIAAAAAAGsHwAA\"]Fc[\"AAAAARgIAAAAAWMRAAABeAIAAAAAAAAAAAAAAAAABS8NAAA1EgAAqRgAAFUZAABqGwAA\",\"AAAAARgIAAAAAAAAAAGtHwAA\"]Cg[\"AAAAAAAAAAAAAAAAAAAAAAAAAtQLAADTFwAA\",\"AAAAAcIeAAA=\"]Gg[\"AAAABqINAABUFAAApxkAAGsbAABlHAAALR0AAAHOGQAAAssZAABNHAAAAAGZHwAAAAAAAAAAAAAAAASjDQAAsRQAAC8WAAAaGQAA\",\"AAABLAMAAA==\"]Fc[\"AAAAAAAAAXECAAAAAfgYAAAAAAAAAAAAAAA6MAAAAQAAAAAACAAQAAAAmAvmDJoNexCkELYQMhIOFQUZ\",\"AAAAAAE8FQAA\"]Fk[\"AAAAAYEIAAAAAAEDAwAAAAAAAAAAAAAAAAA6MAAAAQAAAAAACAAQAAAAVQCUBrkGUwtwELAcox6mHiof\",\"AAAAAYEIAAAB8hUAAA==\"]Fg[\"AAAAAYAIAAAAAAECAwAAAAAAAAAAAAAAAAAHlwgAAKAIAAByDQAAlxAAAFkUAACCFgAA9xgAAA==\",\"AAAAAYAIAAAB8RUAAA==\"]Hc[\"AAAAAicfAACaHwAAB14LAACKCwAAcRwAAC4eAACcHgAABR8AAJ8fAAAEeh0AAEQeAABFHgAAZB8AAAFnHAAAAAAAAAAAAAAAAAABXwsAAA==\",\"AAI+AwAAcQsAAA==\"]Eg[\"AAAAAAABuhEAAAEEAwAAAAAAAAAAAAAAAAA6MAAAAQAAAAAABwAQAAAA7ww5FpAZZhwmHcAe1B72Hg==\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAn0GAAC1FAAA\",\"\"]Ik[\"AAAAA2INAAC7GgAAyB4AAAKsDQAAYxwAAAH3HAAAAAJHAwAACB0AAAN5HQAAYx8AAFsgAAABnh8AAAAAAXAeAAAAAAAAAAbmFwAA/RgAAEEZAAC8GgAAvRoAANccAAA=\",\"AAAAAAAAAAABNQMAAA==\"]Dk[\"AAAAAAABZREAAAAAAAAAAAAAAAAAAARLDQAAgA0AAF4UAABRHAAA\",\"AAAAAAAAAAGTIAAA\"]Ak[\"AAAAAYgLAAABUQoAAA==\",\"\"]Eo[\"AAAAAZEIAAAAAcsRAAABGgMAAAAAAAAAAAAAAAAAA2cUAAA9FgAAcBwAAA==\",\"AAAAAZEIAAABEhYAAAAAAVAeAAA=\"]Ig[\"AAAABTQIAAB2GAAAwhkAAAsdAABBHwAAAnsfAADJHwAAASUfAAACThkAACUdAAAAAAAAAAAAAAAAAAMHCwAAQBIAAC0cAAA=\",\"AAAAAjQIAACiDQAAAAABLQMAAAAAAAAAAAAAAAAAAaMNAAA=\"]Eg[\"AAAAARYIAAAAAWERAAABdgIAAAAAAAAAAAAAAAAAA8YXAACoGQAACxoAAA==\",\"AAAAARYIAAAAAVAgAAA=\"]Fk[\"AAAAAAAAAnICAACUGQAAAAAAAAAAAAAAAAA6MAAAAQAAAAAACgAQAAAAmQvnDJsNfBClELcQZRZtFksZlhm5GQ==\",\"AAAAAAE+FQAA\"]Co[\"AAAAAAAAAf4CAAAAAAAAAAAAAAAAAAJxBgAAWhwAAA==\",\"AAAAAAHoFQAA\"]Dc[\"AAAAAAABbREAAAGHAgAAAAAAAAAAAAAAAAACex0AAPceAAA=\",\"AAAAAU4fAAA=\"]Co[\"AAAAAAAAAQkDAAAAAAAAAAAAAAAAAAIpGQAAUBsAAA==\",\"AAAAAAH1FQAA\"]Dg[\"AAAAAAABkREAAAG9AgAAAAAAAAAAAAAAAAAFrAsAAJkQAACTGAAAsxkAAEsbAAA=\",\"\"]Go[\"AAAAAsIGAADbCAAAA3sEAABiHAAAyR8AAAH5HAAAAX8NAAAAAAAAAAAAAAAAADowAAABAAAAAAAHABAAAABiFOQXARlUGcEaox3EHtke\",\"AAAAAAAAAAFHAwAA\"]Ak[\"AAAAAAAAAvYcAADHHwAA\",\"\"]Cg[\"AAAAAAAAAAAAAAAAAAAAAAAABNEXAACNGAAAoRoAAKocAAA=\",\"\"]Cg[\"AAAAAAAAAAAAAAAAAAAAAAAABNIXAACOGAAAohoAAKwcAAA=\",\"\"]Bc[\"AAAAAAABuh8AAAAAAAABvh8AAA==\",\"\"]Ak[\"AAAAAAAAAAABQx4AAA==\",\"\"]Dc[\"AAAAAAAAAYsCAAAAAAAAAYcfAAAAAAAAAAAChA0AAPQRAAA=\",\"AAAAAAFRFQAA\"]Dk[\"AAAAARwIAAAAAXARAAABjQIAAAAAAAAAAAAAAAAAAWwNAAA=\",\"AAAAARwIAAABVBUAAA==\"]AFk[\"\",\"AAAAADowAAABAAAAAAB6ABAAAACIFosWjBaPFpEWkxaUFpcWmRabFpwWnxagFqMWpRamFqgWqhatFq8WsBazFrQWtha4FroWvBa+FsEWwhbEFsYWyRbLFswWzhbRFtIW1BbXFtgW2xbcFt8W4RbiFuQW5xbpFusW7BbvFvAW8hb0FvcW+Rb6Fv0W/xYBFwIXBRcGFwkXChcMFw4XEBcTFxQXFhcYFxoXHBcfFyAXIhckFyYXKBcrFywXLhcxFzIXNRc3FzkXOhc9Fz4XQBdDF0UXRxdIF0sXTRdOF1EXUhdUF1YXWBdaF1wXXxdhF2MXZBdnF2kXaxdsF24XcRdyF3UXdhd4F3sXfRc=\"]Do[\"AAAAAAAAAYECAAAAAAAAAAAAAAFQHwAAAAAE3QsAADINAABEDgAANhIAAA==\",\"AAAAAAFHFQAA\"]Ek[\"AAAAAnwIAAAtFgAAAAG4EQAAAfgCAAAAAAAAAAAAAAAAAAFSAAAA\",\"AAAAAXwIAAAB3BUAAAAAAAAAAVMbAAA=\"]Co[\"AAAAAAAAAf0CAAAAAAAAAAAAAAAAAAKrCAAAeB0AAA==\",\"AAAAAAHmFQAA\"]Fc[\"AAAAASYIAAAAAXoRAAABlwIAAAAAAAAAAAAAAAAABM4RAAChHAAAaR4AAKcfAAA=\",\"AAAAASYIAAABaRUAAAAAAAGwIAAA\"]Cg[\"AAAAAAABbBEAAAGGAgAA\",\"AAAAAAAAAAAAAAAAAAAAAAG8HwAA\"]Co[\"AAAAAAAAAAJQHgAA+B4AAAJRHgAA+R4AAAAAAxkZAADaGQAAqh4AAA==\",\"\"]Cg[\"AAAAAAAAAfwCAAAAAAAAAAAAAAAAAAFTAAAA\",\"AAAAAAHkFQAA\"]Co[\"AAAAAAABbyAAAAABnB8AAANGHgAA9B4AAF0gAAAAAQoaAAAAAQAdAAA=\",\"\"]Eo[\"AAAAAAABUCAAAAF9IAAAAAEOGgAAAmIbAAD6HAAAAATRGQAAtRsAAD8fAABaHwAAAAACQh8AAFEfAAABkRkAAA==\",\"\"]Eg[\"AAAAARAIAAAAAVwRAAABbQIAAAAAAfocAAAAAAAAAAAAAAL6EQAAixgAAA==\",\"AAAAARAIAAABNRUAAA==\"]Ck[\"AAAAAAABkhEAAAG+AgAAAAAAAAAAAAAAAAADrQsAALQZAABMGwAA\",\"\"]Bg[\"AAAAAAABBx8AAAAAAAE6HwAAARoVAAA=\",\"\"]Ck[\"AAAAAXkYAAAAAAIcAwAAHQMAAA==\",\"AAAAAAAAAAABSg0AAAEfHgAA\"]Dc[\"AAAAATMIAAAAAYURAAABpgIAAAAAAAAAAAAAAAAAAXcYAAA=\",\"AAAAATMIAAA=\"]Dc[\"AAAAAAABkxEAAAG/AgAAAAAAAAAAAAAAAAAErgsAAJoQAAC1GQAATRsAAA==\",\"\"]Ck[\"AAAAAAABth8AAAAAAZAeAAABSx8AAAGGHwAAARsfAAAAAdoeAAA=\",\"\"]Cg[\"AAAAAAABaxEAAAGFAgAA\",\"AAAAAAAAAAAAAAAAAAAAAb8fAAA=\"]ADg[\"AAAAAAAAA1sdAABYHwAApyAAADowAAABAAAAAAAMABAAAABUGz8e5x6VH5YfWSBuIHkgiyCVIJcgnCCuIAdVGwAABR0AAFIdAACNIAAAoCAAAK8gAAC3IAAAOjAAAAEAAAAAAAoAEAAAAFEdUx2ZHt4e5h7EH3ogeyCMIJYgtCAHUxsAAJMfAACUHwAAayAAAG0gAACkIAAAqCAAADowAAABAAAAAAAJABAAAAAZGfIcHB9aH4cfbCCYIJkgmiCfIAACiR8AALggAAAC2hwAAEIfAAAFkRkAAPEcAABPHQAAUB8AAL8fAAABvB8AAA==\",\"\"]Bo[\"AAAAAQ4IAAAAAVoRAAABawIAAA==\",\"AAAAAQ4IAAA=\"]Dc[\"AAAAAAAAAWkCAAAAAAAAAAAAAAAAAAPHBgAA1xEAAMEXAAA=\",\"AAAAAAEuFQAA\"]Eo[\"AAAAAAAFPh8AAKofAABmIAAAcyAAAH8gAAABZyAAAAF+IAAAAZAeAAABSx8AAAGGHwAAAAAB2h4AAAAB8RwAAA==\",\"\"]Ec[\"AAAAAAABTh0AAAABHR8AAAABUxYAAAJYHAAA2RwAAANQFgAAUhsAAIQgAAAAAAACTx0AAFAfAAA=\",\"\"]Hc[\"AAAAAAACtB8AAIMgAAAC0xwAAIggAAAHURYAAJUYAAA3GQAA2BwAAFAdAACTIAAAlCAAAANSFgAA2xwAAJ0gAAABUxYAAAACUBYAAFIbAAAAAAACTx0AAFAfAAA=\",\"\"]Eo[\"AAAAAAAAAXACAAAAAAAAAT8fAAAAAAAAAAAHlwsAAOUMAACjEAAAMRIAAMUUAADMFAAADRUAAA==\",\"AAAAAAE7FQAA\"]Ak[\"AAAAAYkLAAABUgoAAA==\",\"\"]") \ 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..dc27849e --- /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","legacy_player.rs","media.rs","media_player.rs","mod.rs","mpv_backend.rs","mpv_player.rs","mpv_render.rs","native_video.rs","queue.rs","seek.rs","session.rs","sleep_timer.rs","state.rs","stream_end.rs","video_surface.rs"]],["repository",[],["device_profile.rs","exclusions.rs","hybrid.rs","mod.rs","offline.rs","online.rs","series_progress.rs","stream_selection.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,1657]} \ 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..db6df522 --- /dev/null +++ b/api/src/jellytau_lib/commands/player/mod.rs.html @@ -0,0 +1,3565 @@ +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, StreamSelection,
+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    /// How this item's stream is fetched, as the backend decided it.
+183    ///
+184    /// Carried on the queue item so a later seek/reload does not have to guess.
+185    /// `None` for items queued by a path that never negotiated (audio tracks,
+186    /// direct URLs) and for anything queued before this field existed, where the
+187    /// caller falls back to `needs_transcoding` — every transcode this app
+188    /// requests is HLS (DR-140), so that fallback is exact rather than a guess.
+189    ///
+190    /// TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
+191    #[serde(default)]
+192    pub transport: Option<crate::repository::Transport>,
+193
+194    /// Optional now-playing metadata. Used by the background-audio handoff so the
+195    /// lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
+196    /// existing video-only callers need not send them.
+197    #[serde(default)]
+198    pub artist: Option<String>,
+199    #[serde(default)]
+200    pub primary_image_tag: Option<String>,
+201    #[serde(default)]
+202    pub server_id: Option<String>,
+203    /// Total media duration (seconds). Threaded through the background-audio
+204    /// handoff so the lockscreen MediaSession advertises a real duration — a
+205    /// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
+206    #[serde(default)]
+207    pub duration_seconds: Option<f64>,
+208    /// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
+209    /// background-audio handoff so an episode played as audio-only is still
+210    /// recognised as an episode by autoplay (UR-040) and advances to the next one.
+211    #[serde(default)]
+212    pub item_type: Option<String>,
+213    /// Series ID for TV episodes. Needed alongside `item_type` so the backend can
+214    /// look up the next episode when a background-audio track ends.
+215    #[serde(default)]
+216    pub series_id: Option<String>,
+217    /// Subtitle tracks to sideload, with URLs the frontend has already resolved.
+218    ///
+219    /// Only the native backends use these: on Android they become the
+220    /// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
+221    /// builds its own `<track>` children instead and ignores this list.
+222    ///
+223    /// **Order is the contract.** `player_set_subtitle_track(n)` reaches
+224    /// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
+225    /// *text track groups* — i.e. the position of the sideloaded configuration,
+226    /// not the Jellyfin stream index (which is kept on each entry for the UI's
+227    /// benefit). So `n` must be a position in this very array, and the array
+228    /// must not be reordered or filtered between building it and sending it.
+229    /// `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
+230    /// list that is sent here, for exactly this reason.
+231    ///
+232    /// Defaulted so the background-audio handoff and the autoplay/next-episode
+233    /// callers, which have no subtitles to offer, need not send the field.
+234    ///
+235    /// TRACES: UR-020 | IR-016, JA-008 | UT-145
+236    #[serde(default)]
+237    pub subtitles: Vec<crate::player::SubtitleTrack>,
+238}
+239
+240/// Queue context for remote transfer - what type of queue is this?
+241#[derive(specta::Type, Debug, Deserialize)]
+242#[serde(tag = "type", rename_all = "lowercase")]
+243pub enum PlayQueueContext {
+244    /// Playing from a specific album
+245    Album {
+246        #[serde(rename = "albumId")]
+247        album_id: String,
+248        #[serde(rename = "albumName")]
+249        album_name: String,
+250    },
+251    /// Playing from a specific playlist
+252    Playlist {
+253        #[serde(rename = "playlistId")]
+254        playlist_id: String,
+255        #[serde(rename = "playlistName")]
+256        playlist_name: String,
+257    },
+258    /// Custom queue (search results, manual queue, etc.)
+259    Custom,
+260}
+261
+262/// Request to play a queue of items
+263#[derive(specta::Type, Debug, Deserialize)]
+264#[serde(rename_all = "camelCase")]
+265pub struct PlayQueueRequest {
+266    pub items: Vec<PlayItemRequest>,
+267    pub start_index: usize,
+268    pub shuffle: bool,
+269    /// Optional context for the queue (album, playlist, or custom)
+270    /// Used for remote playback transfer
+271    #[serde(default)]
+272    pub context: Option<PlayQueueContext>,
+273}
+274
+275/// Request to play a track from an album (backend fetches all tracks)
+276#[derive(specta::Type, Debug, Deserialize)]
+277#[serde(rename_all = "camelCase")]
+278pub struct PlayAlbumTrackRequest {
+279    pub album_id: String,
+280    pub album_name: String,
+281    pub track_id: String,
+282    pub shuffle: bool,
+283}
+284
+285/// Request to play tracks by ID (backend fetches metadata)
+286#[derive(specta::Type, Debug, Deserialize)]
+287#[serde(rename_all = "camelCase")]
+288pub struct PlayTracksRequest {
+289    pub track_ids: Vec<String>,
+290    pub start_index: usize,
+291    pub shuffle: bool,
+292    pub context: PlayTracksContext,
+293    /// Position (seconds) to resume the starting track from. Used when taking
+294    /// over playback from a remote session so we don't restart from 0.
+295    #[serde(default)]
+296    pub start_position: Option<f64>,
+297}
+298
+299/// Context information for track playback
+300#[derive(specta::Type, Debug, Deserialize)]
+301#[serde(tag = "type", rename_all = "lowercase")]
+302pub enum PlayTracksContext {
+303    Playlist {
+304        #[serde(rename = "playlistId")]
+305        playlist_id: String,
+306        #[serde(rename = "playlistName")]
+307        playlist_name: String,
+308    },
+309    Search {
+310        #[serde(rename = "searchQuery")]
+311        #[allow(dead_code)] // Used for deserialization, may be used later for analytics
+312        search_query: String,
+313    },
+314    Custom {
+315        #[serde(rename = "label")]
+316        #[allow(dead_code)] // Used for deserialization, may be used later for UI display
+317        label: Option<String>,
+318    },
+319}
+320
+321/// Response for video seek operations
+322#[derive(specta::Type, Debug, Serialize)]
+323#[serde(tag = "strategy", rename_all = "camelCase")]
+324pub enum VideoSeekResponse {
+325    /// Use native seeking (HLS or direct stream)
+326    Native {
+327        /// Confirmed position after seek
+328        position: f64,
+329    },
+330    /// Reload stream from new position (transcoded non-HLS)
+331    ReloadStream {
+332        /// What to open, and how — transport included, so the frontend picks
+333        /// its loader from a tagged enum rather than by searching the URL for
+334        /// `.m3u8`. TRACES: UR-079 | DR-225
+335        selection: StreamSelection,
+336        /// `seek_offset` carries the position to RESUME AT, not a base to add to
+337        /// the element's clock. The reloaded stream starts at the item's zero —
+338        /// a position on an HLS playlist makes the server 400 every segment
+339        /// behind it (DR-181) — so the adapter reaches the position by seeking
+340        /// the element and leaves the transcode offset at zero.
+341        seek_offset: f64,
+342    },
+343}
+344
+345/// Response for audio track switching operations
+346#[derive(specta::Type, Debug, Serialize)]
+347#[serde(tag = "strategy", rename_all = "camelCase")]
+348pub enum AudioTrackSwitchResponse {
+349    /// Native backend handled it (Android ExoPlayer)
+350    Native {
+351        /// Confirmation message
+352        success: bool,
+353    },
+354    /// HTML5 needs to reload stream with new audio track
+355    ReloadStream {
+356        /// What to open, and how. TRACES: UR-079 | DR-225
+357        selection: StreamSelection,
+358        /// Current position to resume from
+359        position: f64,
+360    },
+361}
+362
+363/// Response for a mid-playback streaming-quality change.
+364///
+365/// Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
+366/// has to reload anything, so no strategy branch lives in the UI.
+367///
+368/// TRACES: UR-074 | DR-162
+369#[derive(specta::Type, Debug, Serialize)]
+370#[serde(tag = "strategy", rename_all = "camelCase")]
+371pub enum StreamQualityResponse {
+372    /// The native backend was reloaded here; nothing left for the frontend to
+373    /// *do* — but it still has to be told what was negotiated.
+374    ///
+375    /// This carried only a position at first, which left the picker on Android
+376    /// pinned to the rendition of the *first* stream: the UI derives the rung in
+377    /// force from the selection it holds, nothing replaced that selection on the
+378    /// native path, and a transcode always has a rendition — so the fallback
+379    /// that would have used the requested value was never reached. The stream
+380    /// changed and the menu did not.
+381    ///
+382    /// TRACES: UR-074, UR-079 | DR-226, DR-227
+383    Native {
+384        /// What the backend actually opened, so the UI reflects it rather than
+385        /// assuming the request was honoured verbatim.
+386        selection: StreamSelection,
+387        /// Position playback resumed at.
+388        position: f64,
+389    },
+390    /// HTML5 must reload its element with this selection.
+391    ReloadStream {
+392        /// What to open, and how — already negotiated against the requested
+393        /// ceiling. Carries `available` too, so a picker opened after a quality
+394        /// change still describes the source correctly.
+395        /// TRACES: UR-070, UR-079 | DR-225, DR-227
+396        selection: StreamSelection,
+397        /// Position to resume from.
+398        position: f64,
+399    },
+400}
+401
+402/// Helper function to create MediaItem from video request
+403///
+404/// PlayItemRequest is now video-only, so we create a video MediaItem.
+405/// Audio playback uses player_play_tracks which fetches full metadata from backend.
+406pub(super) async fn create_media_item(
+407    req: PlayItemRequest,
+408    db: Option<&DatabaseWrapper>,
+409) -> Result<MediaItem, String> {
+410    // For video-only requests, we use the item ID as the jellyfin ID
+411    let jellyfin_id = req.id.clone();
+412
+413    // Check if item is downloaded locally
+414    let local_path = if let Some(db_wrapper) = db {
+415        check_for_local_download(db_wrapper, &jellyfin_id).await?
+416    } else {
+417        None
+418    };
+419
+420    let source = if let Some(path) = local_path {
+421        MediaSource::Local {
+422            file_path: PathBuf::from(path),
+423            jellyfin_item_id: Some(jellyfin_id.clone()),
+424        }
+425    } else {
+426        MediaSource::Remote {
+427            stream_url: req.stream_url,
+428            jellyfin_item_id: jellyfin_id.clone(),
+429        }
+430    };
+431
+432    Ok(MediaItem {
+433        id: req.id.clone(),
+434        title: req.title.clone(),
+435        name: Some(req.title.clone()),
+436        artist: None,            // Not available from video-only request
+437        album: None,             // Not available from video-only request
+438        album_name: None,        // Not available from video-only request
+439        album_id: None,          // Not available from video-only request
+440        artist_items: None,      // Not available from video-only request
+441        artists: None,           // Not available from video-only request
+442        primary_image_tag: None, // Not available from video-only request
+443        image_id: None,
+444        item_type: None,   // Not available from video-only request
+445        playlist_id: None, // Not available from video-only request
+446        duration: None,    // Not available from video-only request
+447        artwork_url: None, // Not available from video-only request
+448        media_type: crate::player::MediaType::Video, // Video-only request
+449        source,
+450        video_codec: Some(req.video_codec),
+451        needs_transcoding: req.needs_transcoding,
+452        // The caller's negotiated transport, when it had one. TRACES: UR-079 | DR-230
+453        transport: req.transport,
+454        video_width: None,  // Not available from video-only request
+455        video_height: None, // Not available from video-only request
+456        // Sideloaded subtitles, in the order the frontend sent them — that order
+457        // is what `player_set_subtitle_track(n)` indexes into on Android.
+458        // TRACES: UR-020 | IR-016 | UT-145
+459        subtitles: req.subtitles,
+460        series_id: None, // Not available from video-only request
+461        server_id: None, // Not available from video-only request
+462    })
+463}
+464
+465/// Pick the source for an audio-only handoff.
+466///
+467/// A downloaded file wins over the audio-only stream URL. No transcode or audio
+468/// extraction is involved or wanted: the native backends already play a video
+469/// container without decoding its video — the Linux MPV backend is configured
+470/// with `video: no`, and ExoPlayer simply has no surface to render to when the
+471/// item is `MediaType::Audio`. Producing a separate audio-only file would cost
+472/// CPU and battery, need an encoder the project does not ship, and leave a
+473/// second artifact to keep in step with the first.
+474///
+475/// TRACES: UR-071 | DR-128 | UT-119
+476pub(super) fn background_audio_source(
+477    local_path: Option<String>,
+478    stream_url: String,
+479    item_id: &str,
+480) -> MediaSource {
+481    match local_path {
+482        Some(path) => MediaSource::Local {
+483            file_path: PathBuf::from(path),
+484            jellyfin_item_id: Some(item_id.to_string()),
+485        },
+486        None => MediaSource::Remote {
+487            stream_url,
+488            jellyfin_item_id: item_id.to_string(),
+489        },
+490    }
+491}
+492
+493/// How a background-audio handoff must start playback, given where its audio
+494/// actually begins.
+495///
+496/// TRACES: UR-040, UR-071 | DR-180 | UT-181
+497pub(super) struct BackgroundAudioPlan {
+498    /// The position the stream's own zero corresponds to, recorded as the
+499    /// handoff base so later readings can be shifted back to the episode's
+500    /// timeline.
+501    pub base_seconds: f64,
+502    /// Where to seek after loading, if the source does not already start there.
+503    pub seek_to: Option<f64>,
+504}
+505
+506/// Decide the base and the seek for a handoff at `position_seconds`.
+507///
+508/// The two sources start in different places. An audio-only **stream** is built
+509/// with `StartTimeTicks`, so the server makes the handoff point that stream's
+510/// zero: the base is the handoff position, and seeking would skip *past* the
+511/// content by that much again. A downloaded **file** has no such parameter and
+512/// begins at the episode's own zero, so it needs the opposite — no base, and a
+513/// real seek. Treating a file like a stream is why backgrounding a downloaded
+514/// episode restarted it from 0:00 while the lockscreen showed the right time.
+515///
+516/// TRACES: UR-040, UR-071 | DR-180 | UT-181
+517pub(super) fn background_audio_plan(
+518    is_local_file: bool,
+519    position_seconds: f64,
+520) -> BackgroundAudioPlan {
+521    let position = position_seconds.max(0.0);
+522
+523    if is_local_file {
+524        BackgroundAudioPlan {
+525            base_seconds: 0.0,
+526            seek_to: (position > 0.0).then_some(position),
+527        }
+528    } else {
+529        BackgroundAudioPlan {
+530            base_seconds: position,
+531            seek_to: None,
+532        }
+533    }
+534}
+535
+536/// Resolve the on-disk file backing a completed download, if there is one.
+537///
+538/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
+539/// deletion, a cleared cache directory, a restored database). Every caller wants
+540/// "can I play this from disk right now", so existence is checked here rather
+541/// than trusted from the row.
+542///
+543/// Split out from [`check_for_local_download`] so the resolution is testable
+544/// without a `DatabaseWrapper`, and reusable by the video path.
+545///
+546/// TRACES: UR-071 | DR-123 | UT-116
+547pub(super) async fn resolve_local_media_path<S: DatabaseService>(
+548    db_service: &Arc<S>,
+549    item_id: &str,
+550) -> Result<Option<String>, String> {
+551    let query = Query::with_params(
+552        "SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
+553        vec![QueryParam::String(item_id.to_string())],
+554    );
+555
+556    let path: Option<String> = db_service
+557        .query_optional(query, |row| row.get(0))
+558        .await
+559        .map_err(|e| e.to_string())?;
+560
+561    match path {
+562        Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path),
+563        Some(file_path) => {
+564            warn!(
+565                "[Player] Download entry exists in DB but file not found: {}",
+566                file_path
+567            );
+568            Ok(None)
+569        }
+570        None => Ok(None),
+571    }
+572}
+573
+574/// Check if an item has a completed download
+575pub(super) async fn check_for_local_download(
+576    db: &DatabaseWrapper,
+577    item_id: &str,
+578) -> Result<Option<String>, String> {
+579    let db_service = {
+580        let database = db.0.lock().map_err(|e| e.to_string())?;
+581        Arc::new(database.service())
+582    };
+583
+584    resolve_local_media_path(&db_service, item_id).await
+585}
+586
+587/// The on-disk path for a downloaded item, for playback surfaces that resolve
+588/// their own source rather than going through the queue.
+589///
+590/// The video player is the reason this exists: audio has preferred local files
+591/// since queue construction, but video asks the repository for a stream URL and
+592/// never consults `downloads`, so a downloaded film was still streamed — costing
+593/// bandwidth that had already been spent and failing outright when offline.
+594///
+595/// Returns `None` when nothing is downloaded *or* the file is missing, so the
+596/// caller falls back to streaming.
+597///
+598/// TRACES: UR-071 | DR-123 | UT-116
+599#[tauri::command]
+600#[specta::specta]
+601pub async fn player_local_media_path(
+602    db: State<'_, DatabaseWrapper>,
+603    item_id: String,
+604) -> Result<Option<String>, String> {
+605    let db_service = {
+606        let database = db.0.lock().map_err(|e| e.to_string())?;
+607        Arc::new(database.service())
+608    };
+609
+610    resolve_local_media_path(&db_service, &item_id).await
+611}
+612
+613/// Re-point queued streaming items at completed local downloads.
+614///
+615/// Sources are resolved once when the queue is built, so downloads that finish
+616/// while it plays (preloaded upcoming tracks) — or that existed before the
+617/// connection dropped — would otherwise keep streaming. Called before advancing
+618/// so the next track always prefers the on-disk copy.
+619///
+620/// Returns the number of items switched to a local source.
+621pub(super) async fn refresh_queue_local_sources(
+622    controller: &PlayerController,
+623    db: &DatabaseWrapper,
+624) -> Result<usize, String> {
+625    // Collect remote item IDs first; the queue lock must not be held across awaits.
+626    let remote_ids: Vec<String> = {
+627        let queue = controller.queue();
+628        let queue_lock = queue.lock().map_err(|e| e.to_string())?;
+629        queue_lock
+630            .items()
+631            .iter()
+632            .filter_map(|item| match &item.source {
+633                MediaSource::Remote {
+634                    jellyfin_item_id, ..
+635                } => Some(jellyfin_item_id.clone()),
+636                _ => None,
+637            })
+638            .collect()
+639    };
+640
+641    if remote_ids.is_empty() {
+642        return Ok(0);
+643    }
+644
+645    let mut local_paths: Vec<(String, String)> = Vec::new();
+646    for id in remote_ids {
+647        if let Some(path) = check_for_local_download(db, &id).await? {
+648            local_paths.push((id, path));
+649        }
+650    }
+651
+652    if local_paths.is_empty() {
+653        return Ok(0);
+654    }
+655
+656    let queue = controller.queue();
+657    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
+658    let mut switched = 0;
+659    for item in queue_lock.items_mut() {
+660        if let MediaSource::Remote {
+661            jellyfin_item_id, ..
+662        } = &item.source
+663        {
+664            if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) {
+665                info!(
+666                    "[Player] Switching queued track {} to local download: {}",
+667                    id, path
+668                );
+669                item.source = MediaSource::Local {
+670                    file_path: PathBuf::from(path),
+671                    jellyfin_item_id: Some(id.clone()),
+672                };
+673                switched += 1;
+674            }
+675        }
+676    }
+677    Ok(switched)
+678}
+679
+680/// Play a single media item (audio or video)
+681///
+682/// Accepts a PlayItemRequest with all optional fields properly defaulted.
+683/// This avoids Tauri's Android serialization issues with complex objects.
+684///
+685/// @req: UR-003 - Play videos
+686/// @req: UR-004 - Play audio uninterrupted
+687/// @req: UR-005 - Control media playback (play operation)
+688/// @req: DR-009 - Audio player UI
+689#[tauri::command]
+690#[specta::specta]
+691pub async fn player_play_item(
+692    player: State<'_, PlayerStateWrapper>,
+693    session: State<'_, MediaSessionManagerWrapper>,
+694    db: State<'_, DatabaseWrapper>,
+695    item: PlayItemRequest,
+696) -> Result<PlayerStatus, String> {
+697    info!(
+698        "player_play_item called: {} - {}",
+699        item.title, item.stream_url
+700    );
+701
+702    // A ceiling chosen from the in-player picker belongs to the playback it was
+703    // chosen for. Starting a different item returns to the device default —
+704    // otherwise "2 Mbps, just for this one film" quietly governs the rest of the
+705    // session, which is the defect DR-226 exists to close.
+706    //
+707    // TRACES: UR-074, UR-079 | DR-226
+708    crate::repository::online::clear_playback_quality_override();
+709
+710    // Create media item, checking for local download first
+711    let media_item = create_media_item(item, Some(&db)).await?;
+712
+713    // Start appropriate session based on media type
+714    {
+715        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+716        match media_item.media_type {
+717            MediaType::Audio => {
+718                session_mgr.start_audio_session(media_item.clone());
+719            }
+720            MediaType::Video => {
+721                // For single video items, treat as Movie (no series_id available here)
+722                session_mgr.start_movie_session(media_item.clone());
+723            }
+724        }
+725    }
+726
+727    let controller = player.0.lock().await;
+728    // Who gets the stream depends on who is going to *render* it, which is a
+729    // runtime question, not a platform constant.
+730    //
+731    // Historically Linux video was always the webview's (`use_html5_element`),
+732    // so handing the file to MPV as well would only have started a redundant
+733    // decode with no window to show it in — hence a `#[cfg(not(linux))]` guard
+734    // and a queue-only path here. With mpv drawing the picture that inverts:
+735    // the webview is no longer loading anything, so if this does not load the
+736    // file, *nothing does*. The symptom is total silence — no picture and no
+737    // audio — which reads like a broken stream rather than a stream nobody was
+738    // given.
+739    //
+740    // This is the fifth place in this cycle where a renderer's capability was
+741    // written as a compile-time platform fact. Same fix as the others: ask.
+742    //
+743    // TRACES: UR-080 | DR-231, DR-235
+744    let renders_natively = cfg!(not(target_os = "linux")) || crate::player::native_video::enabled();
+745    if renders_natively {
+746        controller
+747            .play_item(media_item)
+748            .map_err(|e| e.to_string())?;
+749    } else {
+750        // The webview will play it; keep the queue in sync for the UI and for a
+751        // remote transfer without starting a second decode.
+752        controller
+753            .set_current_item(media_item)
+754            .map_err(|e| e.to_string())?;
+755    }
+756
+757    // Emit queue changed event
+758    controller.emit_queue_changed();
+759
+760    // Emit session changed event
+761    if let Some(emitter) = controller.event_emitter() {
+762        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+763        emitter.emit(PlayerStatusEvent::SessionChanged {
+764            session: session_mgr.current().clone(),
+765        });
+766    }
+767
+768    Ok(get_player_status(&controller))
+769}
+770
+771/// Enter background-audio mode: hand playback of the currently-watched video off
+772/// to the native ExoPlayer *audio* path so the audio keeps playing while the app
+773/// is backgrounded/locked, with no client-side video decode (UR-040).
+774///
+775/// `stream_url` MUST be an audio-only URL (see
+776/// `get_audio_only_stream_url_for_video`). The item is created as
+777/// `MediaType::Audio` so it starts an audio session and loads into the native
+778/// backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
+779/// frontend side, so exactly one audio source is ever active.
+780///
+781/// This deliberately goes through the queue-based `play_item` path (NOT a
+782/// side-channel) so end-of-track lands in `on_playback_ended`, which already
+783/// honors the sleep timer (Time/Episodes/EndOfTrack) and drives autoplay-next.
+784/// The sleep-timer state is intentionally left untouched by the handoff.
+785///
+786/// TRACES: UR-040 | DR-052 | UT-061, IT-013
+787#[tauri::command]
+788#[specta::specta]
+789pub async fn player_enter_background_audio(
+790    player: State<'_, PlayerStateWrapper>,
+791    session: State<'_, MediaSessionManagerWrapper>,
+792    db: State<'_, DatabaseWrapper>,
+793    item: PlayItemRequest,
+794    position_seconds: f64,
+795) -> Result<PlayerStatus, String> {
+796    info!(
+797        "player_enter_background_audio: {} @ {:.1}s",
+798        item.title, position_seconds
+799    );
+800
+801    // Prefer the downloaded file over the audio-only stream URL the frontend
+802    // resolved. Handing the native backend a local video container yields
+803    // audio-only playback for free — no transcode, no second artifact.
+804    // TRACES: UR-071 | DR-128
+805    let local_path = check_for_local_download(&db, &item.id).await?;
+806    if local_path.is_some() {
+807        info!(
+808            "player_enter_background_audio: using downloaded file for {}",
+809            item.id
+810        );
+811    }
+812    // A downloaded file starts at the episode's zero; a stream starts at the
+813    // handoff point. Only one of them has a base, and only the other needs a seek.
+814    let plan = background_audio_plan(local_path.is_some(), position_seconds);
+815    let source = background_audio_source(local_path, item.stream_url, &item.id);
+816
+817    // Build an AUDIO media item pointing at the audio-only stream. We do not use
+818    // create_media_item() because that hardcodes MediaType::Video; background
+819    // audio must be Audio so no video decode is started.
+820    let media_item = MediaItem {
+821        // Audio and direct-URL items never negotiate a transport.
+822        transport: None,
+823        id: item.id.clone(),
+824        title: item.title.clone(),
+825        name: Some(item.title.clone()),
+826        artist: item.artist.clone(),
+827        album: None,
+828        album_name: None,
+829        album_id: None,
+830        artist_items: None,
+831        artists: None,
+832        primary_image_tag: item.primary_image_tag.clone(),
+833        image_id: item.primary_image_tag.clone(),
+834        // Carry episode identity so autoplay can advance to the next episode when
+835        // this audio-only handoff ends while backgrounded (UR-040).
+836        item_type: item.item_type.clone(),
+837        playlist_id: None,
+838        // Carry the real duration so the lockscreen MediaSession can draw a scrubber.
+839        duration: item.duration_seconds,
+840        artwork_url: None,
+841        media_type: MediaType::Audio,
+842        source,
+843        video_codec: None,
+844        needs_transcoding: false,
+845        video_width: None,
+846        video_height: None,
+847        subtitles: vec![],
+848        series_id: item.series_id.clone(),
+849        server_id: item.server_id.clone(),
+850    };
+851
+852    {
+853        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+854        session_mgr.start_audio_session(media_item.clone());
+855    }
+856
+857    // Same base offset drives the lockscreen scrubber: ExoPlayer reports position
+858    // relative to the stream's StartTimeTicks zero, but the metadata duration is
+859    // absolute, so shift the reported position back to absolute for the scrubber.
+860    let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
+861
+862    let controller = player.0.lock().await;
+863    // Remember where the video was: for a stream the audio's zero == this
+864    // position (the URL was built with StartTimeTicks=position_seconds), so on
+865    // exit we add this base to the native player's relative position to get the
+866    // absolute one. The controller owns it so a backend-driven advance to the
+867    // next episode clears it along with the stream it described.
+868    controller.enter_background_audio(plan.base_seconds);
+869    controller
+870        .play_item(media_item)
+871        .map_err(|e| e.to_string())?;
+872    // Seek ONLY a local file. The audio-only URL already starts at the handoff
+873    // position via StartTimeTicks — its timeline begins at 0 == that point — so
+874    // seeking a stream would jump PAST the content by the handoff position again.
+875    if let Some(seek_to) = plan.seek_to {
+876        info!(
+877            "player_enter_background_audio: seeking the downloaded file to {:.1}s",
+878            seek_to
+879        );
+880        controller.seek(seek_to).map_err(|e| e.to_string())?;
+881    }
+882
+883    controller.emit_queue_changed();
+884    if let Some(emitter) = controller.event_emitter() {
+885        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+886        emitter.emit(PlayerStatusEvent::SessionChanged {
+887            session: session_mgr.current().clone(),
+888        });
+889    }
+890
+891    Ok(get_player_status(&controller))
+892}
+893
+894/// Exit background-audio mode: stop the native audio player and return its final
+895/// position so the frontend can reload the WebView `<video>` there (UR-040).
+896///
+897/// Returns the position in seconds. The sleep timer is intentionally left
+898/// untouched — if it fired while backgrounded, playback is already stopped and
+899/// this simply reports the last position.
+900///
+901/// What playback should do now that the app is no longer visible.
+902///
+903/// The caller supplies only what it alone knows -- whether the per-player
+904/// toggle is armed, and whether Android put the window into picture-in-picture.
+905/// Everything else (what is playing, and therefore whether there is a picture to
+906/// lose) is read here, because it is domain state.
+907///
+908/// The rule itself is in `player::background_policy`; this command is the wire.
+909/// Returning `KeepPlaying` for an empty queue is deliberate: with nothing
+910/// playing there is nothing to pause, and an error would make the frontend
+911/// handle a case that is not a failure.
+912///
+913/// TRACES: UR-040, UR-041 | DR-225 | UT-212
+914#[tauri::command]
+915#[specta::specta]
+916pub async fn player_background_action(
+917    player: State<'_, PlayerStateWrapper>,
+918    background_audio_armed: bool,
+919    in_picture_in_picture: bool,
+920) -> Result<crate::player::background_policy::BackgroundAction, String> {
+921    use crate::player::background_policy::{background_action, is_video_media, BackgroundAction};
+922
+923    let is_video = {
+924        let controller = player.0.lock().await;
+925        let queue_arc = controller.queue();
+926        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
+927        match queue.current() {
+928            Some(item) => is_video_media(item.media_type),
+929            None => return Ok(BackgroundAction::KeepPlaying),
+930        }
+931    };
+932
+933    let action = background_action(is_video, background_audio_armed, in_picture_in_picture);
+934    info!(
+935        "[player_background_action] video={} armed={} pip={} -> {:?}",
+936        is_video, background_audio_armed, in_picture_in_picture, action
+937    );
+938    Ok(action)
+939}
+940
+941/// TRACES: UR-040 | DR-052 | UT-061, IT-013
+942#[tauri::command]
+943#[specta::specta]
+944pub async fn player_exit_background_audio(
+945    player: State<'_, PlayerStateWrapper>,
+946) -> Result<f64, String> {
+947    let controller = player.0.lock().await;
+948
+949    // Read the position BEFORE clearing either base. The position tick applies the
+950    // base natively, so a tick landing between "base cleared" and "position read"
+951    // would hand back a relative position — the whole bug, reintroduced at the one
+952    // moment it matters most. Capturing into a `let` before stop() is also the
+953    // lock discipline from CLAUDE.md: never hold work across a re-entrant call.
+954    // (DR-159)
+955    //
+956    // `absolute_position` rather than `position`, because a tick that has not
+957    // landed *yet* is the same hazard from the other side: returning to the
+958    // foreground while the audio-only transcode is still opening read 0.0, and
+959    // the video reloaded at StartTimeTicks=0 — the episode restarting from the
+960    // beginning. Flooring at the handoff base cannot overshoot: the stream is
+961    // physically incapable of being behind its own starting point. (DR-178)
+962    let absolute = controller.absolute_position();
+963
+964    // Now safe to tear the handoff down, native side first.
+965    let _ = crate::player::set_lockscreen_position_offset(0.0);
+966    controller.exit_background_audio();
+967    controller.stop().map_err(|e| e.to_string())?;
+968    info!(
+969        "player_exit_background_audio: resuming the video at {:.1}s",
+970        absolute
+971    );
+972    Ok(absolute)
+973}
+974
+975/// Play a queue of media items
+976///
+977/// @req: UR-004 - Play audio uninterrupted
+978/// @req: UR-005 - Control media playback (queue playback)
+979/// @req: UR-015 - View and manage current audio queue
+980/// @req: DR-005 - Queue manager with shuffle, repeat, history
+981#[tauri::command]
+982#[specta::specta]
+983pub async fn player_play_queue(
+984    player: State<'_, PlayerStateWrapper>,
+985    session: State<'_, MediaSessionManagerWrapper>,
+986    db: State<'_, DatabaseWrapper>,
+987    request: PlayQueueRequest,
+988) -> Result<PlayerStatus, String> {
+989    info!(
+990        "player_play_queue called: {} items, start_index: {}, shuffle: {}",
+991        request.items.len(),
+992        request.start_index,
+993        request.shuffle
+994    );
+995
+996    // A ceiling chosen from the in-player picker belongs to the playback it was
+997    // chosen for. Starting a different item returns to the device default —
+998    // otherwise "2 Mbps, just for this one film" quietly governs the rest of the
+999    // session, which is the defect DR-226 exists to close.
+1000    //
+1001    // TRACES: UR-074, UR-079 | DR-226
+1002    crate::repository::online::clear_playback_quality_override();
+1003
+1004    // Handle shuffle first
+1005    if request.shuffle {
+1006        let controller = player.0.lock().await;
+1007        controller.toggle_shuffle();
+1008    }
+1009
+1010    // Convert request context to internal QueueContext
+1011    let queue_context = match request.context {
+1012        Some(PlayQueueContext::Album {
+1013            album_id,
+1014            album_name,
+1015        }) => QueueContext::Album {
+1016            album_id,
+1017            album_name,
+1018        },
+1019        Some(PlayQueueContext::Playlist {
+1020            playlist_id,
+1021            playlist_name,
+1022        }) => QueueContext::Playlist {
+1023            playlist_id,
+1024            playlist_name,
+1025        },
+1026        Some(PlayQueueContext::Custom) | None => QueueContext::Custom,
+1027    };
+1028
+1029    // Create media items, checking for local downloads (must not hold locks during await)
+1030    let mut items: Vec<MediaItem> = Vec::new();
+1031    for req in request.items {
+1032        items.push(create_media_item(req, Some(&db)).await?);
+1033    }
+1034
+1035    // Start appropriate session based on first item's media type
+1036    if let Some(first_item) = items.get(request.start_index) {
+1037        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+1038        match first_item.media_type {
+1039            MediaType::Audio => {
+1040                session_mgr.start_audio_session(first_item.clone());
+1041            }
+1042            MediaType::Video => {
+1043                // Queue of videos treated as movie session (TV episodes use different flow)
+1044                session_mgr.start_movie_session(first_item.clone());
+1045            }
+1046        }
+1047    }
+1048
+1049    // Now play the queue and get status
+1050    let controller = player.0.lock().await;
+1051    info!(
+1052        "player_play_queue: Calling controller.play_queue with {} items at index {}",
+1053        items.len(),
+1054        request.start_index
+1055    );
+1056    controller
+1057        .play_queue(items, request.start_index)
+1058        .map_err(|e| e.to_string())?;
+1059
+1060    // Set the queue context for remote transfer
+1061    {
+1062        let queue_arc = controller.queue();
+1063        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1064        queue.set_context(queue_context);
+1065    }
+1066
+1067    // Emit queue changed event
+1068    controller.emit_queue_changed();
+1069
+1070    // Emit session changed event
+1071    if let Some(emitter) = controller.event_emitter() {
+1072        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+1073        emitter.emit(PlayerStatusEvent::SessionChanged {
+1074            session: session_mgr.current().clone(),
+1075        });
+1076    }
+1077
+1078    Ok(get_player_status(&controller))
+1079}
+1080
+1081#[tauri::command]
+1082#[specta::specta]
+1083pub async fn player_play(
+1084    player: State<'_, PlayerStateWrapper>,
+1085    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1086) -> Result<PlayerStatus, String> {
+1087    // Check if we're in remote mode
+1088    let mode = playback_mode.0.get_mode();
+1089
+1090    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1091        // Send play command to remote session - clone client before await
+1092        let client = {
+1093            let controller = player.0.lock().await;
+1094            let client_arc = controller.jellyfin_client();
+1095            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1096            client_opt
+1097                .as_ref()
+1098                .ok_or("Jellyfin client not configured")?
+1099                .clone()
+1100        };
+1101        client.send_session_command(session_id, "Unpause").await?;
+1102    } else {
+1103        // Local playback
+1104        let controller = player.0.lock().await;
+1105        controller.play().map_err(|e| e.to_string())?;
+1106    }
+1107
+1108    let controller = player.0.lock().await;
+1109    Ok(get_player_status(&controller))
+1110}
+1111
+1112#[tauri::command]
+1113#[specta::specta]
+1114pub async fn player_pause(
+1115    player: State<'_, PlayerStateWrapper>,
+1116    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1117) -> Result<PlayerStatus, String> {
+1118    // Check if we're in remote mode
+1119    let mode = playback_mode.0.get_mode();
+1120
+1121    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1122        // Send pause command to remote session - clone client before await
+1123        let client = {
+1124            let controller = player.0.lock().await;
+1125            let client_arc = controller.jellyfin_client();
+1126            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1127            client_opt
+1128                .as_ref()
+1129                .ok_or("Jellyfin client not configured")?
+1130                .clone()
+1131        };
+1132        client.send_session_command(session_id, "Pause").await?;
+1133    } else {
+1134        // Local playback
+1135        let controller = player.0.lock().await;
+1136        controller.pause().map_err(|e| e.to_string())?;
+1137    }
+1138
+1139    let controller = player.0.lock().await;
+1140    Ok(get_player_status(&controller))
+1141}
+1142
+1143#[tauri::command]
+1144#[specta::specta]
+1145pub async fn player_toggle(
+1146    player: State<'_, PlayerStateWrapper>,
+1147    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1148) -> Result<PlayerStatus, String> {
+1149    // Check if we're in remote mode
+1150    let mode = playback_mode.0.get_mode();
+1151
+1152    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1153        // Send toggle command to remote session - clone client before await
+1154        let client = {
+1155            let controller = player.0.lock().await;
+1156            let client_arc = controller.jellyfin_client();
+1157            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1158            client_opt
+1159                .as_ref()
+1160                .ok_or("Jellyfin client not configured")?
+1161                .clone()
+1162        };
+1163        client.send_session_command(session_id, "PlayPause").await?;
+1164    } else {
+1165        // Local playback
+1166        let controller = player.0.lock().await;
+1167        controller.toggle_playback().map_err(|e| e.to_string())?;
+1168    }
+1169
+1170    let controller = player.0.lock().await;
+1171    Ok(get_player_status(&controller))
+1172}
+1173
+1174#[tauri::command]
+1175#[specta::specta]
+1176pub async fn player_stop(
+1177    player: State<'_, PlayerStateWrapper>,
+1178    session: State<'_, MediaSessionManagerWrapper>,
+1179    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1180) -> Result<PlayerStatus, String> {
+1181    // Check if we're in remote mode
+1182    let mode = playback_mode.0.get_mode();
+1183
+1184    // Stopping is a state transition worth seeing in a log. Native video is
+1185    // what made its absence matter: the webview <video> stopped implicitly when
+1186    // the component unmounted, so nothing ever had to call this — and "never
+1187    // called" and "called but the backend kept playing" look identical from
+1188    // outside without it.
+1189    info!("[player_stop] called (mode: {:?})", mode);
+1190
+1191    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1192        // Send stop command to remote session - clone client before await
+1193        let client = {
+1194            let controller = player.0.lock().await;
+1195            let client_arc = controller.jellyfin_client();
+1196            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1197            client_opt
+1198                .as_ref()
+1199                .ok_or("Jellyfin client not configured")?
+1200                .clone()
+1201        };
+1202        client.send_session_command(session_id, "Stop").await?;
+1203
+1204        // Stopping the remote session ends the cast, so the manager returns to
+1205        // Idle — same as a local stop. This is also what hands OS volume control
+1206        // back to this device: set_mode releases the Android remote volume
+1207        // provider on any exit from remote mode. Without it the mode stayed
+1208        // Remote and the system volume slider remained stuck on the remote
+1209        // session with no way back to the local speaker.
+1210        playback_mode
+1211            .0
+1212            .set_mode(crate::playback_mode::PlaybackMode::Idle);
+1213    } else {
+1214        // Local playback
+1215        let controller = player.0.lock().await;
+1216        controller.stop().map_err(|e| e.to_string())?;
+1217
+1218        // A genuine local stop returns the manager to Idle so it no longer
+1219        // reports Local (or a stale Remote) — otherwise a later play/pause would
+1220        // route to the wrong device.
+1221        playback_mode
+1222            .0
+1223            .set_mode(crate::playback_mode::PlaybackMode::Idle);
+1224
+1225        // Handle session state based on type (local playback only)
+1226        {
+1227            let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+1228            let current_session = session_mgr.current().clone();
+1229
+1230            match current_session {
+1231                crate::player::MediaSessionType::Movie { .. } => {
+1232                    // Movies auto-dismiss when stopped
+1233                    session_mgr.movie_session_ended();
+1234                }
+1235                crate::player::MediaSessionType::Audio { .. } => {
+1236                    // Audio persists as inactive (user can resume later)
+1237                    session_mgr.audio_session_inactive();
+1238                }
+1239                crate::player::MediaSessionType::TvShow { .. } => {
+1240                    // TV shows mark episode ended (waiting for next or dismiss)
+1241                    session_mgr.tv_session_episode_ended();
+1242                }
+1243                crate::player::MediaSessionType::Idle => {
+1244                    // Already idle, no-op
+1245                }
+1246            }
+1247
+1248            // Emit session changed event
+1249            if let Some(emitter) = controller.event_emitter() {
+1250                emitter.emit(PlayerStatusEvent::SessionChanged {
+1251                    session: session_mgr.current().clone(),
+1252                });
+1253            }
+1254        }
+1255    }
+1256
+1257    let controller = player.0.lock().await;
+1258    Ok(get_player_status(&controller))
+1259}
+1260
+1261#[tauri::command]
+1262#[specta::specta]
+1263pub async fn player_next(
+1264    player: State<'_, PlayerStateWrapper>,
+1265    session: State<'_, MediaSessionManagerWrapper>,
+1266    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1267    db: State<'_, DatabaseWrapper>,
+1268) -> Result<PlayerStatus, String> {
+1269    debug!("[player_next] Command called from frontend");
+1270
+1271    // Check if we're in remote mode
+1272    let mode = playback_mode.0.get_mode();
+1273
+1274    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1275        // Send next track command to remote session - clone client before await
+1276        let client = {
+1277            let controller = player.0.lock().await;
+1278            let client_arc = controller.jellyfin_client();
+1279            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1280            client_opt
+1281                .as_ref()
+1282                .ok_or("Jellyfin client not configured")?
+1283                .clone()
+1284        };
+1285        client.send_session_command(session_id, "NextTrack").await?;
+1286    } else {
+1287        // Local playback
+1288        let controller = player.0.lock().await;
+1289        // Prefer downloads that completed since the queue was built
+1290        if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
+1291            warn!("[player_next] Failed to refresh local sources: {}", e);
+1292        }
+1293        controller.next().map_err(|e| e.to_string())?;
+1294        controller.emit_queue_changed();
+1295
+1296        // Update audio session track if in audio session
+1297        let current_item = {
+1298            let queue = controller.queue();
+1299            let queue_lock = queue.lock().map_err(|e| e.to_string())?;
+1300            queue_lock.current().cloned()
+1301        };
+1302
+1303        if let Some(item) = current_item {
+1304            if item.media_type == MediaType::Audio {
+1305                let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+1306                session_mgr.update_audio_track(item);
+1307
+1308                // Emit session changed event
+1309                if let Some(emitter) = controller.event_emitter() {
+1310                    emitter.emit(PlayerStatusEvent::SessionChanged {
+1311                        session: session_mgr.current().clone(),
+1312                    });
+1313                }
+1314            }
+1315        }
+1316    }
+1317
+1318    let controller = player.0.lock().await;
+1319    Ok(get_player_status(&controller))
+1320}
+1321
+1322#[tauri::command]
+1323#[specta::specta]
+1324pub async fn player_previous(
+1325    player: State<'_, PlayerStateWrapper>,
+1326    session: State<'_, MediaSessionManagerWrapper>,
+1327    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1328    db: State<'_, DatabaseWrapper>,
+1329) -> Result<PlayerStatus, String> {
+1330    // Check if we're in remote mode
+1331    let mode = playback_mode.0.get_mode();
+1332
+1333    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1334        // Send previous track command to remote session - clone client before await
+1335        let client = {
+1336            let controller = player.0.lock().await;
+1337            let client_arc = controller.jellyfin_client();
+1338            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1339            client_opt
+1340                .as_ref()
+1341                .ok_or("Jellyfin client not configured")?
+1342                .clone()
+1343        };
+1344        client
+1345            .send_session_command(session_id, "PreviousTrack")
+1346            .await?;
+1347    } else {
+1348        // Local playback
+1349        let controller = player.0.lock().await;
+1350        // Prefer downloads that completed since the queue was built
+1351        if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
+1352            warn!("[player_previous] Failed to refresh local sources: {}", e);
+1353        }
+1354        controller.previous().map_err(|e| e.to_string())?;
+1355        controller.emit_queue_changed();
+1356
+1357        // Update audio session track if in audio session
+1358        let current_item = {
+1359            let queue = controller.queue();
+1360            let queue_lock = queue.lock().map_err(|e| e.to_string())?;
+1361            queue_lock.current().cloned()
+1362        };
+1363
+1364        if let Some(item) = current_item {
+1365            if item.media_type == MediaType::Audio {
+1366                let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+1367                session_mgr.update_audio_track(item);
+1368
+1369                // Emit session changed event
+1370                if let Some(emitter) = controller.event_emitter() {
+1371                    emitter.emit(PlayerStatusEvent::SessionChanged {
+1372                        session: session_mgr.current().clone(),
+1373                    });
+1374                }
+1375            }
+1376        }
+1377    }
+1378
+1379    let controller = player.0.lock().await;
+1380    Ok(get_player_status(&controller))
+1381}
+1382
+1383#[tauri::command]
+1384#[specta::specta]
+1385pub async fn player_seek(
+1386    player: State<'_, PlayerStateWrapper>,
+1387    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1388    position: f64,
+1389) -> Result<PlayerStatus, String> {
+1390    // Check if we're in remote mode
+1391    let mode = playback_mode.0.get_mode();
+1392
+1393    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1394        // Send seek command to remote session - clone client before await
+1395        let client = {
+1396            let controller = player.0.lock().await;
+1397            let client_arc = controller.jellyfin_client();
+1398            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1399            client_opt
+1400                .as_ref()
+1401                .ok_or("Jellyfin client not configured")?
+1402                .clone()
+1403        };
+1404        let position_ticks = (position * 10_000_000.0) as i64;
+1405        client.session_seek(session_id, position_ticks).await?;
+1406    } else {
+1407        // Local playback. seek_absolute, not seek: the position came from the UI,
+1408        // which shows the whole item, so during a background-audio handoff it has
+1409        // to be resolved against the episode's timeline rather than the handoff
+1410        // stream's. (DR-159)
+1411        let controller = player.0.lock().await;
+1412        controller.seek_absolute(position).await?;
+1413    }
+1414
+1415    let controller = player.0.lock().await;
+1416    Ok(get_player_status(&controller))
+1417}
+1418
+1419/// Smart video seeking that decides between native and server-side seeking
+1420///
+1421/// This command analyzes the current video stream and automatically chooses
+1422/// the best seeking strategy:
+1423/// - HLS streams: Use native seeking
+1424/// - Direct play streams: Use native seeking
+1425/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
+1426///
+1427/// For native (non-HTML5) backends, this command handles the entire stream reload
+1428/// internally. For HTML5 backends, it returns the new URL for the frontend to handle.
+1429#[tauri::command]
+1430#[specta::specta]
+1431pub async fn player_seek_video(
+1432    player: State<'_, PlayerStateWrapper>,
+1433    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
+1434    repository_handle: String,
+1435    position: f64,
+1436    media_source_id: Option<String>,
+1437    audio_stream_index: Option<i32>,
+1438    use_html5: bool,
+1439) -> Result<VideoSeekResponse, String> {
+1440    info!(
+1441        "[player_seek_video] Seeking to {} seconds (use_html5: {})",
+1442        position, use_html5
+1443    );
+1444
+1445    // Get repository
+1446    let repository = repository_manager
+1447        .0
+1448        .get(&repository_handle)
+1449        .ok_or("Repository not found - user may need to log in")?;
+1450
+1451    // Get current playing item to analyze stream characteristics
+1452    // Clone what we need to avoid holding locks across await points
+1453    let (needs_transcoding, jellyfin_item_id, is_local) = {
+1454        let controller = player.0.lock().await;
+1455        let queue_arc = controller.queue();
+1456        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1457
+1458        let current_item = queue.current().ok_or("No item currently playing")?;
+1459
+1460        if current_item.media_type != MediaType::Video {
+1461            return Err("Current item is not a video".to_string());
+1462        }
+1463
+1464        let jellyfin_id = current_item
+1465            .jellyfin_id()
+1466            .ok_or("Current video has no Jellyfin ID")?
+1467            .to_string();
+1468
+1469        // Neither the URL nor the item's transport is read here any more. The
+1470        // strategy turns on whether the *engine* can seek a transcode in place,
+1471        // which it declares for itself — so the container the stream happens to
+1472        // arrive in stopped being a proxy for anything (DR-246).
+1473        let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
+1474
+1475        (current_item.needs_transcoding, jellyfin_id, is_local_file)
+1476    }; // Locks are dropped here
+1477
+1478    // Whether a transcode can be seeked in place is asked of the engine that is
+1479    // rendering, not guessed from the URL's shape or from who is rendering.
+1480    // TRACES: UR-040, UR-079 | DR-238, DR-246
+1481    let seeks_transcoded_in_place = {
+1482        let controller = player.0.lock().await;
+1483        controller.capabilities().seeks_transcoded_in_place
+1484    };
+1485    let strategy = determine_video_seek_strategy(
+1486        is_local,
+1487        seeks_transcoded_in_place,
+1488        needs_transcoding,
+1489        use_html5,
+1490    );
+1491
+1492    info!(
+1493        "[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
+1494         needs_transcoding={}, use_html5={}, strategy={:?}",
+1495        is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
+1496    );
+1497
+1498    match strategy {
+1499        VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
+1500            // Local files and native backend streams - call backend.seek()
+1501            info!("[player_seek_video] Using backend native seek");
+1502            let controller = player.0.lock().await;
+1503            controller.seek(position).map_err(|e| e.to_string())?;
+1504            Ok(VideoSeekResponse::Native { position })
+1505        }
+1506        VideoSeekStrategy::Html5NativeSeek => {
+1507            // HTML5 backend with HLS or direct play - frontend handles seeking
+1508            // We don't call backend.seek() because video is in HTML5 element, not in MPV
+1509            info!("[player_seek_video] HTML5 native seek - returning position for frontend");
+1510            Ok(VideoSeekResponse::Native { position })
+1511        }
+1512        VideoSeekStrategy::Html5ReloadStream => {
+1513            // Transcoded non-HLS with HTML5 - frontend handles stream reload
+1514            info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
+1515
+1516            let selection = repository
+1517                .get_stream_selection(
+1518                    &jellyfin_item_id,
+1519                    media_source_id.as_deref(),
+1520                    audio_stream_index,
+1521                )
+1522                .await
+1523                .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
+1524
+1525            info!(
+1526                "[player_seek_video] Selected {:?} over {:?} for position {}",
+1527                selection.playback_kind, selection.transport, position
+1528            );
+1529
+1530            Ok(VideoSeekResponse::ReloadStream {
+1531                selection,
+1532                seek_offset: position,
+1533            })
+1534        }
+1535        VideoSeekStrategy::BackendReloadStream => {
+1536            // Transcoded non-HLS with native backend - backend handles stream reload
+1537            info!("[player_seek_video] Backend reload stream - requesting new stream URL");
+1538
+1539            let selection = repository
+1540                .get_stream_selection(
+1541                    &jellyfin_item_id,
+1542                    media_source_id.as_deref(),
+1543                    audio_stream_index,
+1544                )
+1545                .await
+1546                .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
+1547            let new_url = selection.url.clone();
+1548
+1549            info!("[player_seek_video] Got new selection, handling reload internally");
+1550
+1551            // Stop current playback
+1552            {
+1553                let controller = player.0.lock().await;
+1554                controller.stop().map_err(|e| e.to_string())?;
+1555            }
+1556
+1557            // Update the stream URL in the queue
+1558            {
+1559                let controller = player.0.lock().await;
+1560                let queue_arc = controller.queue();
+1561                let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1562
+1563                if !queue.update_current_stream_url(new_url.clone()) {
+1564                    return Err("Failed to update stream URL in queue".to_string());
+1565                }
+1566            }
+1567
+1568            // Reload the player with the updated item from queue
+1569            {
+1570                let controller = player.0.lock().await;
+1571                let queue_arc = controller.queue();
+1572                let queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1573
+1574                if let Some(updated_item) = queue.current() {
+1575                    controller
+1576                        .load_and_play(updated_item)
+1577                        .map_err(|e| e.to_string())?;
+1578                } else {
+1579                    return Err("No current item after URL update".to_string());
+1580                }
+1581
+1582                // The re-opened stream begins at zero — the position cannot ride
+1583                // along in the URL without 400ing every segment (DR-181) — so the
+1584                // seek that the reload was asked for happens here.
+1585                controller.seek(position).map_err(|e| e.to_string())?;
+1586            }
+1587
+1588            info!(
+1589                "[player_seek_video] Stream reloaded successfully at position {}",
+1590                position
+1591            );
+1592
+1593            Ok(VideoSeekResponse::Native { position })
+1594        }
+1595    }
+1596}
+1597
+1598/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
+1599/// Note: Frontend should handle saving series preferences after this command succeeds
+1600///
+1601/// The split is the requirement: an HTML5 `<video>` element cannot be told to
+1602/// change audio track, so the stream is re-opened at the chosen
+1603/// `AudioStreamIndex` and the frontend seeks the reloaded element back to
+1604/// `position`; a native backend (ExoPlayer) switches in place by track-group
+1605/// index. libmpv implements neither — it is the audio-only backend here and
+1606/// leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
+1607/// which is why IR-019 is met by these two paths rather than by MPV.
+1608///
+1609/// TRACES: UR-021 | IR-019, DR-024
+1610#[tauri::command]
+1611#[specta::specta]
+1612// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
+1613// input. Folding the rest into a struct would change the IPC contract and the
+1614// generated TypeScript for no readability gain.
+1615#[allow(clippy::too_many_arguments)]
+1616pub async fn player_switch_audio_track(
+1617    player: State<'_, PlayerStateWrapper>,
+1618    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
+1619    repository_handle: String,
+1620    stream_index: i32,
+1621    array_index: i32,
+1622    use_html5: bool,
+1623    current_position: Option<f64>,
+1624    media_source_id: Option<String>,
+1625) -> Result<AudioTrackSwitchResponse, String> {
+1626    info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
+1627        stream_index, array_index, use_html5);
+1628
+1629    if use_html5 {
+1630        // HTML5 backend needs stream reload
+1631        let repository = repository_manager
+1632            .0
+1633            .get(&repository_handle)
+1634            .ok_or("Repository not found - user may need to log in")?;
+1635
+1636        // Get current item to find Jellyfin ID
+1637        let jellyfin_item_id = {
+1638            let controller = player.0.lock().await;
+1639            let queue_arc = controller.queue();
+1640            let queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1641
+1642            let current_item = queue.current().ok_or("No item currently playing")?;
+1643
+1644            current_item
+1645                .jellyfin_id()
+1646                .ok_or("Current item has no Jellyfin ID")?
+1647                .to_string()
+1648        };
+1649
+1650        // Select a stream carrying the chosen audio track. It starts at zero —
+1651        // an HLS playlist cannot carry a position (DR-181) — and `position`
+1652        // below tells the frontend where to seek the reloaded element back to.
+1653        //
+1654        // Pinning a track is itself a reason the source cannot be direct-played:
+1655        // the file has one default track and the viewer asked for another, so
+1656        // the negotiation returns a transcode. That decision lives in
+1657        // `decide_playback_kind`, not here.
+1658        let selection = repository
+1659            .get_stream_selection(
+1660                &jellyfin_item_id,
+1661                media_source_id.as_deref(),
+1662                Some(stream_index),
+1663            )
+1664            .await
+1665            .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
+1666
+1667        Ok(AudioTrackSwitchResponse::ReloadStream {
+1668            selection,
+1669            position: current_position.unwrap_or(0.0),
+1670        })
+1671    } else {
+1672        // Native backend (Android ExoPlayer) - use array index
+1673        let controller = player.0.lock().await;
+1674        controller
+1675            .set_audio_track(array_index)
+1676            .map_err(|e| e.to_string())?;
+1677
+1678        Ok(AudioTrackSwitchResponse::Native { success: true })
+1679    }
+1680}
+1681
+1682/// Change the bandwidth ceiling of the video that is playing *right now*.
+1683///
+1684/// A cap is a property of the stream the server is producing, so unlike a volume
+1685/// change it cannot be applied to a stream already in flight — the stream has to
+1686/// be re-opened at the new quality and resumed at the current position. That is
+1687/// the same reload the transcoded-seek and audio-track paths use, and the same
+1688/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
+1689/// native backend is reloaded here.
+1690///
+1691/// The change applies to **this playback only**. The in-player picker is a
+1692/// "this film, this connection" control and its doc has always said so, but it
+1693/// used to be implemented by writing the process-wide ceiling — so choosing
+1694/// 2 Mbps to get one awkward film moving silently capped every video played
+1695/// afterwards for the rest of the process, with the Settings screen still
+1696/// showing the old value and nothing in the UI admitting the change. It now
+1697/// sets a per-playback override that the next item clears; the durable default
+1698/// belongs to Settings, and `player_set_video_settings` is the one that writes
+1699/// to the database.
+1700///
+1701/// TRACES: UR-074, UR-079 | DR-162, DR-226
+1702#[tauri::command]
+1703#[specta::specta]
+1704// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
+1705// input. Folding the rest into a struct would change the IPC contract and the
+1706// generated TypeScript for no readability gain.
+1707#[allow(clippy::too_many_arguments)]
+1708pub async fn player_set_stream_quality(
+1709    player: State<'_, PlayerStateWrapper>,
+1710    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
+1711    repository_handle: String,
+1712    quality: crate::settings::StreamingQuality,
+1713    use_html5: bool,
+1714    current_position: Option<f64>,
+1715    media_source_id: Option<String>,
+1716    audio_stream_index: Option<i32>,
+1717) -> Result<StreamQualityResponse, String> {
+1718    info!(
+1719        "[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
+1720        quality.label(),
+1721        use_html5,
+1722        current_position
+1723    );
+1724
+1725    let repository = repository_manager
+1726        .0
+1727        .get(&repository_handle)
+1728        .ok_or("Repository not found - user may need to log in")?;
+1729
+1730    let jellyfin_item_id = {
+1731        let controller = player.0.lock().await;
+1732        let queue_arc = controller.queue();
+1733        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1734
+1735        let current_item = queue.current().ok_or("No item currently playing")?;
+1736
+1737        if current_item.media_type != MediaType::Video {
+1738            return Err("Current item is not a video".to_string());
+1739        }
+1740
+1741        current_item
+1742            .jellyfin_id()
+1743            .ok_or("Current item has no Jellyfin ID")?
+1744            .to_string()
+1745    };
+1746
+1747    // Set the ceiling *before* negotiating — the negotiation and every URL
+1748    // builder resolve through `effective_streaming_quality`, and they have to
+1749    // agree or the cap leaks (a negotiation authorising a direct play the URL
+1750    // builder then never gets to constrain).
+1751    //
+1752    // Deliberately the *override*, not the device default: see the doc above.
+1753    // TRACES: UR-074, UR-079 | DR-226
+1754    crate::repository::online::set_playback_quality_override(quality);
+1755
+1756    // Where to resume. `current_position` is the *element's* clock, which only
+1757    // the webview path has — on a native backend there is no `<video>` and the
+1758    // frontend correctly sends null, so trusting it there resumed every quality
+1759    // change from zero.
+1760    //
+1761    // The player is the authority on position (it is the authority on all
+1762    // playback state); asking the DOM for it and falling back to 0 inverted
+1763    // that. Fall back to what the controller reports instead.
+1764    //
+1765    // TRACES: UR-005, UR-074 | DR-226
+1766    // The guard is bound inside the arm's block so it is dropped before the
+1767    // reload below takes the same lock. This codebase has been bitten by a
+1768    // MutexGuard living longer than the expression that produced it.
+1769    let position = match current_position {
+1770        Some(p) => p,
+1771        None => {
+1772            let controller = player.0.lock().await;
+1773            controller.absolute_position()
+1774        }
+1775    };
+1776    let selection = repository
+1777        .get_stream_selection(
+1778            &jellyfin_item_id,
+1779            media_source_id.as_deref(),
+1780            audio_stream_index,
+1781        )
+1782        .await
+1783        .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
+1784    let new_url = selection.url.clone();
+1785
+1786    if use_html5 {
+1787        return Ok(StreamQualityResponse::ReloadStream {
+1788            selection,
+1789            position,
+1790        });
+1791    }
+1792
+1793    // Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
+1794    // new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
+1795    // The re-opened stream begins at zero (an HLS playlist cannot carry a start
+1796    // position without 400ing every segment — DR-181), so it is seeked back to
+1797    // where the picture was.
+1798    {
+1799        let controller = player.0.lock().await;
+1800        controller.stop().map_err(|e| e.to_string())?;
+1801
+1802        let queue_arc = controller.queue();
+1803        {
+1804            let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1805            if !queue.update_current_stream_url(new_url.clone()) {
+1806                return Err("Failed to update stream URL in queue".to_string());
+1807            }
+1808        }
+1809
+1810        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1811        let updated_item = queue.current().ok_or("No current item after URL update")?;
+1812        controller
+1813            .load_and_play(updated_item)
+1814            .map_err(|e| e.to_string())?;
+1815        if position > 0.0 {
+1816            controller.seek(position).map_err(|e| e.to_string())?;
+1817        }
+1818    }
+1819
+1820    Ok(StreamQualityResponse::Native {
+1821        selection,
+1822        position,
+1823    })
+1824}
+1825
+1826/// Set the active audio track on a native backend directly.
+1827///
+1828/// TRACES: UR-021 | IR-019, DR-024
+1829#[tauri::command]
+1830#[specta::specta]
+1831pub async fn player_set_audio_track(
+1832    player: State<'_, PlayerStateWrapper>,
+1833    stream_index: i32,
+1834) -> Result<PlayerStatus, String> {
+1835    let controller = player.0.lock().await;
+1836    controller
+1837        .set_audio_track(stream_index)
+1838        .map_err(|e| e.to_string())?;
+1839    Ok(get_player_status(&controller))
+1840}
+1841
+1842/// Set (or clear, with `None`) the active subtitle track on a native backend.
+1843///
+1844/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
+1845/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
+1846/// index. The HTML5 path never reaches here; it toggles its own `<track>`
+1847/// children. libmpv implements neither, leaving the trait default in place.
+1848///
+1849/// TRACES: UR-020 | IR-018, DR-023
+1850#[tauri::command]
+1851#[specta::specta]
+1852pub async fn player_set_subtitle_track(
+1853    player: State<'_, PlayerStateWrapper>,
+1854    stream_index: Option<i32>,
+1855) -> Result<PlayerStatus, String> {
+1856    let controller = player.0.lock().await;
+1857    controller
+1858        .set_subtitle_track(stream_index)
+1859        .map_err(|e| e.to_string())?;
+1860    Ok(get_player_status(&controller))
+1861}
+1862
+1863/// Normalise a volume arriving over IPC to the 0.0..=1.0 range every backend
+1864/// works in.
+1865///
+1866/// NaN is handled before the clamp rather than by it: `f32::clamp` returns NaN
+1867/// for a NaN input (it only panics on NaN *bounds*), and NaN then survives every
+1868/// comparison downstream, so a backend clamp cannot catch it either. It is
+1869/// treated as "no volume asked for" and floored to 0.0.
+1870///
+1871/// TRACES: DR-212 | UT-206
+1872fn normalize_volume(volume: f32) -> f32 {
+1873    if volume.is_nan() {
+1874        0.0
+1875    } else {
+1876        volume.clamp(0.0, 1.0)
+1877    }
+1878}
+1879
+1880#[tauri::command]
+1881#[specta::specta]
+1882pub async fn player_set_volume(
+1883    player: State<'_, PlayerStateWrapper>,
+1884    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1885    volume: f32,
+1886) -> Result<PlayerStatus, String> {
+1887    // Clamp at the boundary as well as in each backend: the remote branch below
+1888    // never reaches a backend clamp, and `(f32::INFINITY * 100.0) as i32` would
+1889    // hand the server i32::MAX as a volume percentage.
+1890    // TRACES: DR-212 | UT-206
+1891    let volume = normalize_volume(volume);
+1892
+1893    // Check if we're in remote mode
+1894    let mode = playback_mode.0.get_mode();
+1895
+1896    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1897        // Send volume command to remote session - clone client before await
+1898        let client = {
+1899            let controller = player.0.lock().await;
+1900            let client_arc = controller.jellyfin_client();
+1901            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1902            client_opt
+1903                .as_ref()
+1904                .ok_or("Jellyfin client not configured")?
+1905                .clone()
+1906        };
+1907        // Convert 0-1 range to 0-100 for Jellyfin API
+1908        let volume_percent = (volume * 100.0) as i32;
+1909        client
+1910            .session_set_volume(session_id, volume_percent)
+1911            .await?;
+1912    } else {
+1913        // Local playback
+1914        let controller = player.0.lock().await;
+1915        controller.set_volume(volume).map_err(|e| e.to_string())?;
+1916    }
+1917
+1918    let controller = player.0.lock().await;
+1919    Ok(get_player_status(&controller))
+1920}
+1921
+1922#[tauri::command]
+1923#[specta::specta]
+1924pub async fn player_toggle_mute(
+1925    player: State<'_, PlayerStateWrapper>,
+1926    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1927) -> Result<PlayerStatus, String> {
+1928    // Check if we're in remote mode
+1929    let mode = playback_mode.0.get_mode();
+1930
+1931    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1932        // Send toggle mute command to remote session - clone client before await
+1933        let client = {
+1934            let controller = player.0.lock().await;
+1935            let client_arc = controller.jellyfin_client();
+1936            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1937            client_opt
+1938                .as_ref()
+1939                .ok_or("Jellyfin client not configured")?
+1940                .clone()
+1941        };
+1942        client
+1943            .send_session_command(session_id, "ToggleMute")
+1944            .await?;
+1945    } else {
+1946        // Local playback
+1947        // TODO: Implement toggle_mute in PlayerController
+1948        // let controller = player.0.lock().await;
+1949        // controller.toggle_mute().map_err(|e| e.to_string())?;
+1950    }
+1951
+1952    let controller = player.0.lock().await;
+1953    Ok(get_player_status(&controller))
+1954}
+1955
+1956#[tauri::command]
+1957#[specta::specta]
+1958pub async fn player_toggle_shuffle(
+1959    player: State<'_, PlayerStateWrapper>,
+1960) -> Result<QueueStatus, String> {
+1961    let controller = player.0.lock().await;
+1962    controller.toggle_shuffle();
+1963    controller.emit_queue_changed();
+1964    Ok(get_queue_status(&controller))
+1965}
+1966
+1967#[tauri::command]
+1968#[specta::specta]
+1969pub async fn player_cycle_repeat(
+1970    player: State<'_, PlayerStateWrapper>,
+1971) -> Result<QueueStatus, String> {
+1972    let controller = player.0.lock().await;
+1973    controller.cycle_repeat();
+1974    controller.emit_queue_changed();
+1975    Ok(get_queue_status(&controller))
+1976}
+1977
+1978#[tauri::command]
+1979#[specta::specta]
+1980pub async fn player_get_status(
+1981    player: State<'_, PlayerStateWrapper>,
+1982    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1983) -> Result<PlayerStatus, String> {
+1984    let mode = playback_mode.0.get_mode();
+1985
+1986    // Get base local status
+1987    let controller = player.0.lock().await;
+1988    let mut status = get_player_status(&controller);
+1989
+1990    // Get local media from queue
+1991    let local_media = {
+1992        let queue_arc = controller.queue();
+1993        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1994        queue.current().map(MergedMediaItem::from)
+1995    };
+1996
+1997    let local_is_playing = status.state.is_playing();
+1998    let local_volume = status.volume;
+1999
+2000    // If in remote mode, fetch session and merge state
+2001    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+2002        let client = {
+2003            let client_arc = controller.jellyfin_client();
+2004            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+2005            client_opt
+2006                .as_ref()
+2007                .ok_or("Jellyfin client not configured")?
+2008                .clone()
+2009        };
+2010
+2011        drop(controller); // Release lock before async call
+2012
+2013        match client.get_session(&session_id).await {
+2014            Ok(Some(session)) => {
+2015                log::info!("[PlayerCommands] Merging remote session state");
+2016
+2017                // Merge media item
+2018                status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
+2019
+2020                // Merge isPlaying (NOT isPaused!)
+2021                status.merged_is_playing = session
+2022                    .play_state
+2023                    .as_ref()
+2024                    .map(|ps| !ps.is_paused.unwrap_or(true))
+2025                    .unwrap_or(false);
+2026
+2027                // Merge position (convert ticks to seconds)
+2028                status.position = session
+2029                    .play_state
+2030                    .as_ref()
+2031                    .and_then(|ps| ps.position_ticks)
+2032                    .map(|ticks| ticks as f64 / 10_000_000.0)
+2033                    .unwrap_or(0.0);
+2034
+2035                // Merge duration (convert ticks to seconds)
+2036                status.duration = session
+2037                    .now_playing_item
+2038                    .as_ref()
+2039                    .and_then(|item| item.run_time_ticks)
+2040                    .map(|ticks| ticks as f64 / 10_000_000.0);
+2041
+2042                // Merge volume (convert 0-100 → 0-1)
+2043                status.merged_volume = session
+2044                    .play_state
+2045                    .as_ref()
+2046                    .and_then(|ps| ps.volume_level)
+2047                    .map(|vol| (vol.clamp(0, 100) as f32) / 100.0)
+2048                    .unwrap_or(1.0);
+2049
+2050                return Ok(status);
+2051            }
+2052            Ok(None) => {
+2053                log::warn!("[PlayerCommands] Remote session not found, using local state");
+2054            }
+2055            Err(e) => {
+2056                log::warn!("[PlayerCommands] Failed to fetch remote session: {}", e);
+2057            }
+2058        }
+2059    }
+2060
+2061    // Local playback or fallback
+2062    status.merged_media = local_media;
+2063    status.merged_is_playing = local_is_playing;
+2064    status.merged_volume = local_volume;
+2065
+2066    Ok(status)
+2067}
+2068
+2069#[tauri::command]
+2070#[specta::specta]
+2071pub async fn player_get_queue(
+2072    player: State<'_, PlayerStateWrapper>,
+2073) -> Result<QueueStatus, String> {
+2074    let controller = player.0.lock().await;
+2075    Ok(get_queue_status(&controller))
+2076}
+2077
+2078/// What playback facilities this platform's backend actually provides.
+2079///
+2080/// The frontend is presentation-only and must not re-derive backend facts from
+2081/// `navigator.userAgent` — that sniffing was a second copy of the same platform
+2082/// decision Rust already makes with `cfg!`, and it drifted. These flags are the
+2083/// single source of truth; the frontend consumes them.
+2084///
+2085/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
+2086#[derive(specta::Type, Debug, Serialize)]
+2087#[serde(rename_all = "camelCase")]
+2088pub struct PlaybackCapabilities {
+2089    /// True when audio is rendered by a webview `<audio>` element rather than a
+2090    /// native backend. Native audio exists on Linux (mpv) and Android
+2091    /// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
+2092    pub uses_webview_audio: bool,
+2093    /// True when video can be rendered by a native surface composited *behind*
+2094    /// a transparent webview. Android only: ExoPlayer draws into a SurfaceView
+2095    /// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
+2096    /// compositing), so it stays on the HTML5 element.
+2097    pub supports_native_video: bool,
+2098}
+2099
+2100/// Report this platform's playback capabilities to the frontend.
+2101///
+2102/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
+2103#[tauri::command]
+2104#[specta::specta]
+2105pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
+2106    // Mirrors the cfg gates the backends themselves are built under.
+2107    let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
+2108
+2109    Ok(PlaybackCapabilities {
+2110        uses_webview_audio: !native_audio,
+2111        // TRACES: UR-080 | DR-235
+2112        supports_native_video: cfg!(target_os = "android")
+2113            || crate::player::native_video::enabled(),
+2114    })
+2115}
+2116
+2117pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
+2118    // Determine backend at compile time based on platform
+2119    let (backend, use_html5_element) = if cfg!(target_os = "android") {
+2120        // Android uses ExoPlayer native backend
+2121        (VideoBackend::Native, false)
+2122    } else if crate::player::native_video::enabled() {
+2123        // mpv draws the picture on this desktop; the frontend must not also
+2124        // load it into a <video> element or the stream decodes twice and the
+2125        // two fight over the audio. TRACES: UR-080 | DR-235
+2126        (VideoBackend::Native, false)
+2127    } else {
+2128        // Linux and other platforms use HTML5 video element in frontend
+2129        (VideoBackend::Html5, true)
+2130    };
+2131
+2132    PlayerStatus {
+2133        state: controller.state(),
+2134        // The position on the item's timeline, whichever of the three paths is
+2135        // rendering it — the native backend answers for only one of them, and
+2136        // reads 0 for webview video and for a handoff that has not ticked yet.
+2137        // TRACES: UR-005 | DR-178
+2138        position: controller.absolute_position(),
+2139        duration: controller.duration(),
+2140        volume: controller.volume(),
+2141        muted: controller.muted(),
+2142        shuffle: controller.is_shuffle(),
+2143        repeat: controller.repeat_mode(),
+2144        backend,
+2145        use_html5_element,
+2146
+2147        // Merged fields initialized to defaults (will be set by player_get_status)
+2148        merged_media: None,
+2149        merged_is_playing: false,
+2150        merged_volume: controller.volume(),
+2151    }
+2152}
+2153
+2154pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
+2155    let queue = controller.queue();
+2156    let queue_lock = queue.lock_safe();
+2157
+2158    QueueStatus {
+2159        items: queue_lock.items().to_vec(),
+2160        current_index: queue_lock.current_index(),
+2161        shuffle: queue_lock.is_shuffle(),
+2162        repeat: queue_lock.repeat_mode(),
+2163        has_next: queue_lock.has_next(),
+2164        has_previous: queue_lock.has_previous(),
+2165    }
+2166}
+2167
+2168/// Start a freshly-built queue on the active remote session.
+2169///
+2170/// Used by the "play tracks"/"play album track" commands when we're in remote
+2171/// mode: instead of starting local MPV playback, we cast the selected tracks to
+2172/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's
+2173/// play_on_session call, but for a brand-new selection (so there's no resume
+2174/// position - playback starts from the chosen track's beginning).
+2175///
+2176/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and
+2177/// the start index is adjusted to the remaining Jellyfin items. Returns an error
+2178/// if the selected track itself has no Jellyfin ID.
+2179async fn play_selection_on_remote(
+2180    controller: &PlayerController,
+2181    session_id: &str,
+2182    media_items: &[MediaItem],
+2183    start_index: usize,
+2184) -> Result<(), String> {
+2185    // Collect Jellyfin IDs, tracking where the selected track lands after any
+2186    // local-only items are dropped.
+2187    let mut jellyfin_ids: Vec<String> = Vec::new();
+2188    let mut adjusted_index: Option<usize> = None;
+2189    for (i, item) in media_items.iter().enumerate() {
+2190        if let Some(id) = item.jellyfin_id() {
+2191            if i == start_index {
+2192                adjusted_index = Some(jellyfin_ids.len());
+2193            }
+2194            jellyfin_ids.push(id.to_string());
+2195        }
+2196    }
+2197
+2198    let start_index =
+2199        adjusted_index.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
+2200
+2201    if jellyfin_ids.is_empty() {
+2202        return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
+2203    }
+2204
+2205    let client = {
+2206        let client_arc = controller.jellyfin_client();
+2207        let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+2208        client_opt
+2209            .as_ref()
+2210            .ok_or("Jellyfin client not configured")?
+2211            .clone()
+2212    };
+2213
+2214    // Fresh selection: start from the beginning of the chosen track.
+2215    client
+2216        .play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
+2217        .await
+2218        .map_err(|e| format!("Failed to start playback on remote session: {}", e))
+2219}
+2220
+2221/// Play a track from an album - backend fetches all album tracks and builds queue
+2222#[tauri::command]
+2223#[specta::specta]
+2224pub async fn player_play_album_track(
+2225    player: State<'_, PlayerStateWrapper>,
+2226    session: State<'_, MediaSessionManagerWrapper>,
+2227    db: State<'_, DatabaseWrapper>,
+2228    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
+2229    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+2230    repository_handle: String,
+2231    request: PlayAlbumTrackRequest,
+2232) -> Result<PlayerStatus, String> {
+2233    info!(
+2234        "player_play_album_track called: album_id={}, track_id={}, shuffle={}",
+2235        request.album_id, request.track_id, request.shuffle
+2236    );
+2237
+2238    // Get repository (hybrid - supports offline/online)
+2239    let repository = repository_manager
+2240        .0
+2241        .get(&repository_handle)
+2242        .ok_or("Repository not found - user may need to log in")?;
+2243
+2244    // Fetch all tracks from the album via hybrid repository
+2245    info!(
+2246        "Fetching tracks for album {} via repository",
+2247        request.album_id
+2248    );
+2249    let album_items = repository
+2250        .get_items(
+2251            &request.album_id,
+2252            Some(GetItemsOptions {
+2253                limit: Some(1000),
+2254                fields: Some(vec![
+2255                    "PrimaryImageAspectRatio".to_string(),
+2256                    "Overview".to_string(),
+2257                    "MediaStreams".to_string(),
+2258                ]),
+2259                ..Default::default()
+2260            }),
+2261        )
+2262        .await
+2263        .map_err(|e| format!("Failed to fetch album tracks: {}", e))?;
+2264
+2265    info!("Found {} items in album", album_items.items.len());
+2266
+2267    // Filter to only Audio items and sort by index
+2268    let mut tracks: Vec<_> = album_items
+2269        .items
+2270        .into_iter()
+2271        .filter(|item| item.item_type == "Audio")
+2272        .collect();
+2273    tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
+2274
+2275    info!("Found {} audio tracks in album", tracks.len());
+2276
+2277    if tracks.is_empty() {
+2278        return Err("No audio tracks found in album".to_string());
+2279    }
+2280
+2281    // Debug: Log all track IDs and their indices
+2282    info!("Album has {} tracks after sorting:", tracks.len());
+2283    for (idx, track) in tracks.iter().enumerate() {
+2284        info!("  [{}] {} (ID: {})", idx, track.name, track.id);
+2285    }
+2286
+2287    // Validate the requested track exists in the album (its position in the
+2288    // final queue is computed after building, since offline tracks are skipped).
+2289    info!("Looking for track_id: {}", request.track_id);
+2290    let album_index = tracks
+2291        .iter()
+2292        .position(|t| t.id == request.track_id)
+2293        .ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
+2294
+2295    info!(
+2296        "Track {} is at index {} in album",
+2297        request.track_id, album_index
+2298    );
+2299
+2300    // Convert tracks to MediaItems
+2301    let mut media_items = Vec::new();
+2302    for track in tracks {
+2303        // Check for local download first
+2304        let jellyfin_id = &track.id;
+2305        let local_path = check_for_local_download(&db, jellyfin_id).await?;
+2306
+2307        let source = if let Some(path) = local_path {
+2308            MediaSource::Local {
+2309                file_path: PathBuf::from(path),
+2310                jellyfin_item_id: Some(jellyfin_id.clone()),
+2311            }
+2312        } else {
+2313            // Non-downloaded track: needs a stream URL from the server. When the
+2314            // server is unreachable (offline), skip this track rather than failing
+2315            // the whole album — downloaded tracks must still be playable.
+2316            match repository.get_audio_stream_url(&track.id).await {
+2317                Ok(stream_url) => MediaSource::Remote {
+2318                    stream_url,
+2319                    jellyfin_item_id: jellyfin_id.clone(),
+2320                },
+2321                Err(e) => {
+2322                    warn!(
+2323                        "[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
+2324                        track.name, track.id, e
+2325                    );
+2326                    continue;
+2327                }
+2328            }
+2329        };
+2330
+2331        let primary_image_tag_for_url = track.primary_image_tag.clone();
+2332        let media_item = MediaItem {
+2333            // Audio and direct-URL items never negotiate a transport.
+2334            transport: None,
+2335            id: track.id.clone(),
+2336            title: track.name.clone(),
+2337            name: Some(track.name.clone()), // Frontend compatibility
+2338            artist: track
+2339                .album_artist
+2340                .clone()
+2341                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
+2342            album: Some(request.album_name.clone()),
+2343            album_name: Some(request.album_name.clone()), // Frontend compatibility
+2344            album_id: Some(request.album_id.clone()),
+2345            artist_items: track.artist_items.clone(), // For clickable artist links
+2346            artists: track.artists.clone(),           // Fallback artist info
+2347            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
+2348            image_id: track.primary_image_tag.clone(),
+2349            item_type: Some(track.item_type.clone()), // Frontend compatibility
+2350            playlist_id: None,
+2351            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
+2352            artwork_url: primary_image_tag_for_url.map(|tag| {
+2353                repository.get_image_url(
+2354                    &request.album_id,
+2355                    ImageType::Primary,
+2356                    Some(ImageOptions {
+2357                        max_width: Some(300),
+2358                        tag: Some(tag),
+2359                        ..Default::default()
+2360                    }),
+2361                )
+2362            }),
+2363            media_type: MediaType::Audio,
+2364            source,
+2365            video_codec: None,
+2366            needs_transcoding: false,
+2367            video_width: None,
+2368            video_height: None,
+2369            subtitles: vec![],
+2370            series_id: None,
+2371            server_id: None,
+2372        };
+2373
+2374        media_items.push(media_item);
+2375    }
+2376
+2377    if media_items.is_empty() {
+2378        return Err("No playable tracks available (offline and nothing downloaded)".to_string());
+2379    }
+2380
+2381    // Tracks with no local download and no reachable server were skipped above,
+2382    // so positions shifted. Re-locate the requested track in the built queue.
+2383    // If the tapped track itself was skipped, fall back to the first item.
+2384    let start_index = media_items
+2385        .iter()
+2386        .position(|item| item.id == request.track_id)
+2387        .unwrap_or(0);
+2388
+2389    info!(
+2390        "Built queue with {} media items, starting at index {}",
+2391        media_items.len(),
+2392        start_index
+2393    );
+2394
+2395    // Handle shuffle before setting queue
+2396    if request.shuffle {
+2397        let controller = player.0.lock().await;
+2398        controller.toggle_shuffle();
+2399    }
+2400
+2401    // Start audio session with the first item
+2402    if let Some(first_item) = media_items.get(start_index) {
+2403        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+2404        session_mgr.start_audio_session(first_item.clone());
+2405    }
+2406
+2407    let controller = player.0.lock().await;
+2408
+2409    // When controlling a remote session, cast the selection there instead of
+2410    // starting local MPV playback. We still load the queue locally (below) so
+2411    // the queue/context stay in sync for the UI and for transferring back.
+2412    let remote_session = match playback_mode.0.get_mode() {
+2413        crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
+2414        _ => None,
+2415    };
+2416
+2417    if let Some(session_id) = &remote_session {
+2418        play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
+2419        controller
+2420            .set_queue(media_items, start_index)
+2421            .map_err(|e| e.to_string())?;
+2422    } else {
+2423        // Local playback is now authoritative (see player_play_tracks); set it
+2424        // before starting so the mode-changed event precedes the state events.
+2425        playback_mode
+2426            .0
+2427            .set_mode(crate::playback_mode::PlaybackMode::Local);
+2428        controller
+2429            .play_queue(media_items, start_index)
+2430            .map_err(|e| e.to_string())?;
+2431    }
+2432
+2433    // Set the queue context for remote transfer
+2434    {
+2435        let queue_arc = controller.queue();
+2436        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
+2437        queue.set_context(QueueContext::Album {
+2438            album_id: request.album_id.clone(),
+2439            album_name: request.album_name.clone(),
+2440        });
+2441    }
+2442
+2443    // Emit queue changed event
+2444    controller.emit_queue_changed();
+2445
+2446    // Log final queue state
+2447    {
+2448        let queue_arc = controller.queue();
+2449        let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?;
+2450        info!(
+2451            "player_play_album_track: Queue now has {} items, current_index: {:?}",
+2452            queue_lock.items().len(),
+2453            queue_lock.current_index()
+2454        );
+2455    }
+2456
+2457    // Emit session changed event
+2458    if let Some(emitter) = controller.event_emitter() {
+2459        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+2460        emitter.emit(PlayerStatusEvent::SessionChanged {
+2461            session: session_mgr.current().clone(),
+2462        });
+2463    }
+2464
+2465    Ok(get_player_status(&controller))
+2466}
+2467
+2468/// Play tracks by ID - backend fetches all metadata
+2469#[tauri::command]
+2470#[specta::specta]
+2471pub async fn player_play_tracks(
+2472    player: State<'_, PlayerStateWrapper>,
+2473    session: State<'_, MediaSessionManagerWrapper>,
+2474    db: State<'_, DatabaseWrapper>,
+2475    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
+2476    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+2477    repository_handle: String,
+2478    request: PlayTracksRequest,
+2479) -> Result<PlayerStatus, String> {
+2480    // A ceiling chosen from the in-player picker belongs to the playback it was
+2481    // chosen for. Starting a different item returns to the device default —
+2482    // otherwise "2 Mbps, just for this one film" quietly governs the rest of the
+2483    // session, which is the defect DR-226 exists to close.
+2484    //
+2485    // TRACES: UR-074, UR-079 | DR-226
+2486    crate::repository::online::clear_playback_quality_override();
+2487
+2488    info!(
+2489        "player_play_tracks called: {} tracks, start_index={}, shuffle={}",
+2490        request.track_ids.len(),
+2491        request.start_index,
+2492        request.shuffle
+2493    );
+2494
+2495    // Validate input
+2496    if request.track_ids.is_empty() {
+2497        return Err("No tracks provided".to_string());
+2498    }
+2499
+2500    // Get repository
+2501    let repository = repository_manager
+2502        .0
+2503        .get(&repository_handle)
+2504        .ok_or("Repository not found - user may need to log in")?;
+2505
+2506    // Fetch metadata for all tracks
+2507    let mut media_items = Vec::new();
+2508    for track_id in &request.track_ids {
+2509        // Fetch track metadata from repository
+2510        let track = repository
+2511            .get_item(track_id)
+2512            .await
+2513            .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
+2514
+2515        // Check for local download
+2516        let local_path = check_for_local_download(&db, track_id).await?;
+2517
+2518        // Build MediaSource
+2519        let source = if let Some(path) = local_path {
+2520            MediaSource::Local {
+2521                file_path: PathBuf::from(path),
+2522                jellyfin_item_id: Some(track.id.clone()),
+2523            }
+2524        } else {
+2525            let stream_url = repository
+2526                .get_audio_stream_url(track_id)
+2527                .await
+2528                .map_err(|e| format!("Failed to get stream URL: {}", e))?;
+2529
+2530            MediaSource::Remote {
+2531                stream_url,
+2532                jellyfin_item_id: track.id.clone(),
+2533            }
+2534        };
+2535
+2536        // Transform to MediaItem with frontend-compatible fields
+2537        let primary_image_tag_for_url = track.primary_image_tag.clone();
+2538        let media_item = MediaItem {
+2539            // Audio and direct-URL items never negotiate a transport.
+2540            transport: None,
+2541            id: track.id.clone(),
+2542            title: track.name.clone(),
+2543            name: Some(track.name.clone()), // Frontend compatibility
+2544            artist: track
+2545                .album_artist
+2546                .clone()
+2547                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
+2548            album: track.album_name.clone(),
+2549            album_name: track.album_name.clone(), // Frontend compatibility
+2550            album_id: track.album_id.clone(),
+2551            artist_items: track.artist_items.clone(), // For clickable artist links
+2552            artists: track.artists.clone(),           // Fallback artist info
+2553            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
+2554            image_id: track.primary_image_tag.clone(),
+2555            item_type: Some(track.item_type.clone()), // Frontend compatibility
+2556            playlist_id: None,                        // Set based on context below
+2557            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
+2558            artwork_url: primary_image_tag_for_url.and_then(|tag| {
+2559                track.album_id.as_ref().map(|album_id| {
+2560                    repository.get_image_url(
+2561                        album_id,
+2562                        ImageType::Primary,
+2563                        Some(ImageOptions {
+2564                            max_width: Some(300),
+2565                            tag: Some(tag),
+2566                            ..Default::default()
+2567                        }),
+2568                    )
+2569                })
+2570            }),
+2571            media_type: MediaType::Audio,
+2572            source,
+2573            video_codec: None,
+2574            needs_transcoding: false,
+2575            video_width: None,
+2576            video_height: None,
+2577            subtitles: vec![],
+2578            series_id: None,
+2579            server_id: None,
+2580        };
+2581
+2582        media_items.push(media_item);
+2583    }
+2584
+2585    info!("Built queue with {} media items", media_items.len());
+2586
+2587    // Map context and set playlist_id
+2588    let queue_context = match request.context {
+2589        PlayTracksContext::Playlist {
+2590            playlist_id,
+2591            playlist_name,
+2592        } => {
+2593            for item in &mut media_items {
+2594                item.playlist_id = Some(playlist_id.clone());
+2595            }
+2596            QueueContext::Playlist {
+2597                playlist_id,
+2598                playlist_name,
+2599            }
+2600        }
+2601        PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => QueueContext::Custom,
+2602    };
+2603
+2604    // Handle shuffle
+2605    if request.shuffle {
+2606        let controller = player.0.lock().await;
+2607        controller.toggle_shuffle();
+2608    }
+2609
+2610    // Start session
+2611    if let Some(first_item) = media_items.get(request.start_index) {
+2612        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+2613        session_mgr.start_audio_session(first_item.clone());
+2614    }
+2615
+2616    let controller = player.0.lock().await;
+2617
+2618    // When controlling a remote session, cast the selection there instead of
+2619    // starting local MPV playback. Skip this while a transfer is in flight: the
+2620    // transfer-to-local path calls this command to load the queue locally and
+2621    // the mode is still Remote until the transfer completes - routing it back to
+2622    // the remote would undo the transfer.
+2623    let remote_session = match playback_mode.0.get_mode() {
+2624        crate::playback_mode::PlaybackMode::Remote { session_id }
+2625            if !playback_mode.0.is_transferring() =>
+2626        {
+2627            Some(session_id)
+2628        }
+2629        _ => None,
+2630    };
+2631
+2632    if let Some(session_id) = &remote_session {
+2633        play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
+2634            .await?;
+2635        controller
+2636            .set_queue(media_items, request.start_index)
+2637            .map_err(|e| e.to_string())?;
+2638    } else {
+2639        // Starting local playback makes Local the authoritative mode. Without
+2640        // this, a prior Remote mode lingers in the manager and later play/pause
+2641        // commands route back to the (stopped) remote session. Set it BEFORE
+2642        // starting playback so the PlaybackModeChanged event reaches the frontend
+2643        // ahead of the state_changed events it will emit — otherwise the frontend
+2644        // (still thinking it's remote) filters those state events out. Skip during
+2645        // a transfer: transfer_to_local drives the mode itself once complete.
+2646        if !playback_mode.0.is_transferring() {
+2647            playback_mode
+2648                .0
+2649                .set_mode(crate::playback_mode::PlaybackMode::Local);
+2650        }
+2651        controller
+2652            .play_queue_from(media_items, request.start_index, request.start_position)
+2653            .map_err(|e| e.to_string())?;
+2654    }
+2655
+2656    // Set queue context
+2657    {
+2658        let queue_arc = controller.queue();
+2659        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
+2660        queue.set_context(queue_context);
+2661    }
+2662
+2663    // Emit events
+2664    controller.emit_queue_changed();
+2665    if let Some(emitter) = controller.event_emitter() {
+2666        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+2667        emitter.emit(PlayerStatusEvent::SessionChanged {
+2668            session: session_mgr.current().clone(),
+2669        });
+2670    }
+2671
+2672    info!("player_play_tracks completed successfully");
+2673    Ok(get_player_status(&controller))
+2674}
+2675
+2676/// Response for preload operation
+2677#[derive(specta::Type, Debug, Serialize)]
+2678#[serde(rename_all = "camelCase")]
+2679pub struct PreloadResult {
+2680    /// Number of tracks queued for preload
+2681    pub queued_count: usize,
+2682    /// Number of tracks already downloaded
+2683    pub already_downloaded: usize,
+2684    /// Number of tracks skipped (no jellyfin ID or other reasons)
+2685    pub skipped: usize,
+2686}
+2687
+2688/// Preload upcoming tracks from the queue
+2689/// This queues background downloads for the next N tracks that aren't already downloaded
+2690#[tauri::command]
+2691#[specta::specta]
+2692pub async fn player_preload_upcoming(
+2693    player: State<'_, PlayerStateWrapper>,
+2694    db: State<'_, DatabaseWrapper>,
+2695    smart_cache: State<'_, SmartCacheWrapper>,
+2696    download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
+2697    app: tauri::AppHandle,
+2698    user_id: String,
+2699    _download_base_path: String,
+2700) -> Result<PreloadResult, String> {
+2701    // The pump only starts rows that carry both a stream URL and a target dir,
+2702    // so resolve the same storage root the user-initiated download paths use
+2703    // (storage_get_path = the database's parent directory).
+2704    let (db_service, target_dir) = {
+2705        let database = db.0.lock().map_err(|e| e.to_string())?;
+2706        let target_dir = database
+2707            .path()
+2708            .parent()
+2709            .ok_or_else(|| "Database path has no parent directory".to_string())?
+2710            .to_string_lossy()
+2711            .to_string();
+2712        (Arc::new(database.service()), target_dir)
+2713    };
+2714
+2715    // Get cache settings
+2716    let precache_count = {
+2717        let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
+2718        if !cache.should_precache_queue() {
+2719            return Ok(PreloadResult {
+2720                queued_count: 0,
+2721                already_downloaded: 0,
+2722                skipped: 0,
+2723            });
+2724        }
+2725        cache.queue_precache_count()
+2726    };
+2727
+2728    // Get upcoming items from queue
+2729    let upcoming_items: Vec<MediaItem> = {
+2730        let controller = player.0.lock().await;
+2731        let queue = controller.queue();
+2732        let queue_lock = queue.lock().map_err(|e| e.to_string())?;
+2733        queue_lock
+2734            .get_upcoming(precache_count)
+2735            .into_iter()
+2736            .cloned()
+2737            .collect()
+2738    };
+2739
+2740    if upcoming_items.is_empty() {
+2741        return Ok(PreloadResult {
+2742            queued_count: 0,
+2743            already_downloaded: 0,
+2744            skipped: 0,
+2745        });
+2746    }
+2747
+2748    let mut queued_count = 0;
+2749    let mut already_downloaded = 0;
+2750    let mut skipped = 0;
+2751
+2752    // Process each upcoming item
+2753    for item in upcoming_items {
+2754        // Only process items with Remote source (not already local). The
+2755        // source already carries the resolved stream URL — reuse it so the
+2756        // pump can start the download without any extra resolution step.
+2757        let (jellyfin_id, stream_url) = match &item.source {
+2758            MediaSource::Remote {
+2759                jellyfin_item_id,
+2760                stream_url,
+2761            } => (jellyfin_item_id.clone(), stream_url.clone()),
+2762            MediaSource::Local { .. } => {
+2763                already_downloaded += 1;
+2764                continue;
+2765            }
+2766            MediaSource::DirectUrl { .. } => {
+2767                skipped += 1;
+2768                continue;
+2769            }
+2770        };
+2771
+2772        // Check if already downloaded or actively in flight. Stale pending rows
+2773        // without a stream URL are NOT skipped here — the upsert below heals
+2774        // them so the pump can finally start them.
+2775        let query = Query::with_params(
+2776            "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
+2777             AND (status IN ('completed', 'downloading')
+2778                  OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
+2779            vec![
+2780                QueryParam::String(jellyfin_id.clone()),
+2781                QueryParam::String(user_id.clone()),
+2782            ],
+2783        );
+2784
+2785        let is_downloaded: Option<String> = db_service
+2786            .query_optional(query, |row| row.get(0))
+2787            .await
+2788            .map_err(|e| e.to_string())?;
+2789
+2790        if is_downloaded.is_some() {
+2791            already_downloaded += 1;
+2792            continue;
+2793        }
+2794
+2795        // Queue for download with low priority (preload priority = -100) so
+2796        // user-initiated downloads always win a pump slot first.
+2797        let album_dir = item
+2798            .album
+2799            .as_deref()
+2800            .filter(|a| !a.is_empty())
+2801            .unwrap_or("Unknown Album");
+2802        let file_path = format!(
+2803            "downloads/{}/{}.mp3",
+2804            sanitize_filename(album_dir),
+2805            sanitize_filename(&item.title)
+2806        );
+2807
+2808        // Insert with the stream URL + target dir the pump needs to start it.
+2809        // On conflict, heal pre-existing rows that were queued without a URL
+2810        // (they could never start) instead of leaving them stuck.
+2811        let insert_query = Query::with_params(
+2812            "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)
+2813             VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
+2814             ON CONFLICT(item_id, user_id) DO UPDATE SET
+2815                 stream_url = excluded.stream_url,
+2816                 target_dir = excluded.target_dir
+2817             WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
+2818            vec![
+2819                QueryParam::String(jellyfin_id),
+2820                QueryParam::String(user_id.clone()),
+2821                QueryParam::String(file_path),
+2822                QueryParam::String(item.title.clone()),
+2823                item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
+2824                item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
+2825                QueryParam::String(stream_url),
+2826                QueryParam::String(target_dir.clone()),
+2827            ],
+2828        );
+2829
+2830        match db_service.execute(insert_query).await {
+2831            Ok(rows) if rows > 0 => {
+2832                info!(
+2833                    "[Preload] Queued download for: {} - {}",
+2834                    item.artist.as_deref().unwrap_or("Unknown"),
+2835                    item.title
+2836                );
+2837                queued_count += 1;
+2838            }
+2839            Ok(_) => {
+2840                // Row already exists (conflict), count as already queued
+2841                already_downloaded += 1;
+2842            }
+2843            Err(e) => {
+2844                error!("[Preload] Failed to queue {}: {}", item.title, e);
+2845                skipped += 1;
+2846            }
+2847        }
+2848    }
+2849
+2850    // Kick the pump so the queued preloads actually start; without this they'd
+2851    // only begin once some other download activity pumps the queue.
+2852    if queued_count > 0 {
+2853        let active_downloads = {
+2854            let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
+2855            manager.get_active_downloads()
+2856        };
+2857        crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
+2858    }
+2859
+2860    info!(
+2861        "[Preload] Result: queued={}, already_downloaded={}, skipped={}",
+2862        queued_count, already_downloaded, skipped
+2863    );
+2864
+2865    Ok(PreloadResult {
+2866        queued_count,
+2867        already_downloaded,
+2868        skipped,
+2869    })
+2870}
+2871
+2872/// Sanitize filename by removing invalid characters
+2873fn sanitize_filename(name: &str) -> String {
+2874    name.chars()
+2875        .map(|c| match c {
+2876            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
+2877            _ => c,
+2878        })
+2879        .collect()
+2880}
+2881
+2882/// Update SmartCache configuration
+2883#[tauri::command]
+2884#[specta::specta]
+2885pub async fn player_set_cache_config(
+2886    smart_cache: State<'_, SmartCacheWrapper>,
+2887    config: CacheConfig,
+2888) -> Result<(), String> {
+2889    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
+2890    cache.update_config(config);
+2891    Ok(())
+2892}
+2893
+2894/// Get current SmartCache configuration
+2895#[tauri::command]
+2896#[specta::specta]
+2897pub async fn player_get_cache_config(
+2898    smart_cache: State<'_, SmartCacheWrapper>,
+2899) -> Result<CacheConfig, String> {
+2900    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
+2901    Ok(cache.get_config().unwrap_or_default())
+2902}
+2903
+2904/// Configure Jellyfin API client for automatic playback reporting
+2905#[tauri::command]
+2906#[specta::specta]
+2907pub async fn player_configure_jellyfin(
+2908    player: State<'_, PlayerStateWrapper>,
+2909    db: State<'_, DatabaseWrapper>,
+2910    server_url: String,
+2911    access_token: String,
+2912    user_id: String,
+2913    device_id: String,
+2914) -> Result<(), String> {
+2915    log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
+2916
+2917    let config = JellyfinConfig {
+2918        server_url,
+2919        access_token,
+2920        device_id,
+2921    };
+2922
+2923    // Legacy client (used for remote session control / casting).
+2924    let client = JellyfinClient::new(config.clone())?;
+2925
+2926    // Build the PlaybackReporter the player and backends (MPV + ExoPlayer)
+2927    // actually report through. Without this, Start/Progress/Stopped never reach
+2928    // Jellyfin, so playback position never syncs and you can't resume on another
+2929    // device. The reporter shares the player controller's Arc, so populating it
+2930    // here lights up reporting on both desktop and Android, on every auth path
+2931    // that configures the player (login / restore / reauth).
+2932    let db_service = {
+2933        let database = db.0.lock().map_err(|e| e.to_string())?;
+2934        Arc::new(database.service())
+2935    };
+2936    let reporter_client = JellyfinClient::new(config)?;
+2937    let reporter = crate::playback_reporting::PlaybackReporter::new(
+2938        db_service,
+2939        Arc::new(TokioMutex::new(Some(reporter_client))),
+2940        user_id,
+2941    );
+2942
+2943    let controller = player.0.lock().await;
+2944    controller.set_jellyfin_client(Some(client));
+2945    controller.set_playback_reporter(Some(reporter)).await;
+2946
+2947    log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
+2948    Ok(())
+2949}
+2950
+2951/// Disable Jellyfin automatic playback reporting
+2952#[tauri::command]
+2953#[specta::specta]
+2954pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> Result<(), String> {
+2955    log::info!("[PlayerCommand] Disabling Jellyfin client");
+2956
+2957    let controller = player.0.lock().await;
+2958    controller.set_jellyfin_client(None);
+2959    controller.set_playback_reporter(None).await;
+2960
+2961    log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
+2962    Ok(())
+2963}
+2964
+2965#[cfg(test)]
+2966mod tests {
+2967    use crate::utils::lock::MutexSafe;
+2968
+2969    /// UT-206 — the volume the command hands on is always a real number in
+2970    /// 0.0..=1.0.
+2971    ///
+2972    /// Every backend clamps for itself, but the remote branch of
+2973    /// `player_set_volume` reaches no backend at all: it does
+2974    /// `(volume * 100.0) as i32`, which turns infinity into `i32::MAX` and NaN
+2975    /// into 0. NaN also survives `f32::clamp` unchanged, so clamping alone is
+2976    /// not enough — it has to be tested for.
+2977    ///
+2978    /// TRACES: DR-212 | UT-206
+2979    #[test]
+2980    fn test_normalize_volume_clamps_and_rejects_nan() {
+2981        use super::normalize_volume;
+2982
+2983        // In-range values pass through untouched.
+2984        assert_eq!(normalize_volume(0.0), 0.0);
+2985        assert_eq!(normalize_volume(0.5), 0.5);
+2986        assert_eq!(normalize_volume(1.0), 1.0);
+2987
+2988        // Out of range clamps to the same 0.0..=1.0 the backends use.
+2989        assert_eq!(normalize_volume(-0.5), 0.0);
+2990        assert_eq!(normalize_volume(42.0), 1.0);
+2991        assert_eq!(normalize_volume(f32::INFINITY), 1.0);
+2992        assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
+2993
+2994        // NaN is not a volume; it must not reach the Jellyfin percentage
+2995        // conversion or a backend.
+2996        let from_nan = normalize_volume(f32::NAN);
+2997        assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
+2998        assert_eq!(from_nan, 0.0);
+2999
+3000        // Whatever comes out survives the remote branch's percentage cast.
+3001        for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
+3002            let percent = (normalize_volume(input) * 100.0) as i32;
+3003            assert!((0..=100).contains(&percent), "input {input} gave {percent}");
+3004        }
+3005    }
+3006
+3007    /// The subtitle list the frontend resolved must survive the IPC hop and end
+3008    /// up on the `MediaItem` the native backend loads.
+3009    ///
+3010    /// The bug: `VideoPlayer.svelte` built a fully-resolved subtitle array and
+3011    /// then dropped it on the floor — `PlayItemRequest` had no field to put it
+3012    /// in — so `create_media_item` always produced `subtitles: vec![]`,
+3013    /// `android/mod.rs` serialized `[]` across JNI, and ExoPlayer was handed a
+3014    /// `MediaItem` with zero `SubtitleConfiguration`s. Every later
+3015    /// `setSubtitleTrack(n)` then found no text track groups and logged
+3016    /// "Invalid subtitle track index".
+3017    ///
+3018    /// The payload below is exactly what the frontend sends: camelCase for the
+3019    /// top-level command params (Tauri v2 converts them), and the subtitle
+3020    /// entries in the casing of `SubtitleTrack` itself — note `mime_type`.
+3021    ///
+3022    /// TRACES: UR-020 | IR-016 | UT-145
+3023    #[tokio::test]
+3024    async fn test_play_item_request_carries_subtitles_into_media_item() {
+3025        use super::{create_media_item, PlayItemRequest};
+3026
+3027        let payload = serde_json::json!({
+3028            "id": "ep-1",
+3029            "title": "Pilot",
+3030            "streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
+3031            "videoCodec": "h264",
+3032            "needsTranscoding": false,
+3033            "subtitles": [
+3034                {
+3035                    "index": 2,
+3036                    "url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
+3037                    "language": "eng",
+3038                    "label": "English (SRT)",
+3039                    "mime_type": "text/vtt"
+3040                },
+3041                {
+3042                    "index": 3,
+3043                    "url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
+3044                    "language": null,
+3045                    "label": null,
+3046                    "mime_type": "text/vtt"
+3047                }
+3048            ]
+3049        });
+3050
+3051        let req: PlayItemRequest =
+3052            serde_json::from_value(payload).expect("frontend payload must deserialize");
+3053        assert_eq!(
+3054            req.subtitles.len(),
+3055            2,
+3056            "PlayItemRequest must carry the subtitle tracks, not silently ignore them"
+3057        );
+3058
+3059        let media = create_media_item(req, None).await.unwrap();
+3060        assert_eq!(
+3061            media.subtitles.len(),
+3062            2,
+3063            "create_media_item must thread the tracks onto the MediaItem the backend loads"
+3064        );
+3065        assert_eq!(media.subtitles[0].index, 2);
+3066        assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
+3067        assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
+3068        assert_eq!(media.subtitles[0].mime_type, "text/vtt");
+3069        // Order is the contract: `player_set_subtitle_track(n)` is a position in
+3070        // this list (see the note on `PlayItemRequest::subtitles`).
+3071        assert_eq!(media.subtitles[1].index, 3);
+3072        assert!(media.subtitles[1].language.is_none());
+3073    }
+3074
+3075    /// A request without subtitles must still deserialize — the field is
+3076    /// defaulted so the background-audio handoff and the autoplay/next-episode
+3077    /// callers keep compiling and sending what they always sent.
+3078    ///
+3079    /// TRACES: UR-020 | IR-016 | UT-145
+3080    #[tokio::test]
+3081    async fn test_play_item_request_without_subtitles_defaults_to_empty() {
+3082        use super::{create_media_item, PlayItemRequest};
+3083
+3084        let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
+3085            "id": "movie-1",
+3086            "title": "Movie",
+3087            "streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
+3088            "videoCodec": "h264",
+3089            "needsTranscoding": false
+3090        }))
+3091        .expect("a subtitle-less payload must still deserialize");
+3092
+3093        assert!(req.subtitles.is_empty());
+3094        assert!(create_media_item(req, None)
+3095            .await
+3096            .unwrap()
+3097            .subtitles
+3098            .is_empty());
+3099    }
+3100
+3101    /// The JSON handed to Kotlin over JNI must use the keys
+3102    /// `JellyTauPlayer.load()` actually reads.
+3103    ///
+3104    /// `MediaItem` is `rename_all = "camelCase"`, and the instinct (and the
+3105    /// house IPC rule) is to camelCase nested structs too — but
+3106    /// `JellyTauPlayer.kt` reads `subtitle.optString("mime_type", …)`. Renaming
+3107    /// the field would not fail to compile or fail the IPC; it would silently
+3108    /// fall back to the default MIME type for every track, so this is asserted
+3109    /// on the exact bytes `android/mod.rs` sends.
+3110    ///
+3111    /// TRACES: UR-020 | IR-016, JA-008 | UT-146
+3112    #[test]
+3113    fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
+3114        use crate::player::media::SubtitleTrack;
+3115
+3116        let subtitles = vec![SubtitleTrack {
+3117            index: 2,
+3118            url: "https://jelly.example/subs.vtt".to_string(),
+3119            language: Some("eng".to_string()),
+3120            label: Some("English".to_string()),
+3121            mime_type: "text/vtt".to_string(),
+3122        }];
+3123
+3124        // Exactly what player/android/mod.rs passes to loadWithMetadata.
+3125        let json = serde_json::to_string(&subtitles).unwrap();
+3126        let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
+3127        let obj = parsed[0].as_object().unwrap();
+3128
+3129        for key in ["url", "language", "label", "mime_type"] {
+3130            assert!(
+3131                obj.contains_key(key),
+3132                "JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
+3133                obj.keys().collect::<Vec<_>>()
+3134            );
+3135        }
+3136        assert!(
+3137            !obj.contains_key("mimeType"),
+3138            "camelCasing mime_type silently drops every track's MIME type on Android"
+3139        );
+3140    }
+3141
+3142    /// The audio-only handoff must play a downloaded file when there is one,
+3143    /// rather than fetching an audio-only stream for media already on disk.
+3144    ///
+3145    /// TRACES: UR-071 | DR-128 | UT-119
+3146    #[test]
+3147    fn test_background_audio_source_prefers_local_file() {
+3148        use super::background_audio_source;
+3149        use crate::player::MediaSource;
+3150        use std::path::PathBuf;
+3151
+3152        let local = background_audio_source(
+3153            Some("/downloads/ep1.mkv".to_string()),
+3154            "https://server/audio-only".to_string(),
+3155            "ep-1",
+3156        );
+3157        match local {
+3158            MediaSource::Local {
+3159                file_path,
+3160                jellyfin_item_id,
+3161            } => {
+3162                assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
+3163                // The Jellyfin id must survive so progress still syncs back.
+3164                assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
+3165            }
+3166            other => panic!("expected a local source, got {:?}", other),
+3167        }
+3168
+3169        let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
+3170        match remote {
+3171            MediaSource::Remote {
+3172                stream_url,
+3173                jellyfin_item_id,
+3174            } => {
+3175                assert_eq!(stream_url, "https://server/audio-only");
+3176                assert_eq!(jellyfin_item_id, "ep-1");
+3177            }
+3178            other => panic!("expected a remote source, got {:?}", other),
+3179        }
+3180    }
+3181
+3182    /// The two sources start in different places, so the handoff cannot treat
+3183    /// them alike.
+3184    ///
+3185    /// An audio-only *stream* is built with `StartTimeTicks`, so the server makes
+3186    /// the handoff point that stream's zero: the base is the handoff position and
+3187    /// seeking would jump past the content. A *downloaded file* has no such
+3188    /// parameter — it starts at the episode's own zero — so basing it at the
+3189    /// handoff position claims 18 minutes of audio that is about to play from the
+3190    /// beginning. That is the downloaded-episode version of "it restarts when the
+3191    /// screen sleeps", and it needs the opposite treatment: no base, and a seek.
+3192    ///
+3193    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
+3194    #[test]
+3195    fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
+3196        use super::background_audio_plan;
+3197
+3198        let local = background_audio_plan(true, 1104.0);
+3199        assert_eq!(local.base_seconds, 0.0);
+3200        assert_eq!(local.seek_to, Some(1104.0));
+3201
+3202        let streamed = background_audio_plan(false, 1104.0);
+3203        assert_eq!(streamed.base_seconds, 1104.0);
+3204        assert_eq!(
+3205            streamed.seek_to, None,
+3206            "the URL already starts at the handoff point; seeking again skips past it"
+3207        );
+3208    }
+3209
+3210    /// Handing off at the very start has nothing to seek to and nothing to base:
+3211    /// both sources are already where they need to be.
+3212    ///
+3213    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
+3214    #[test]
+3215    fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
+3216        use super::background_audio_plan;
+3217
+3218        for local in [true, false] {
+3219            let plan = background_audio_plan(local, 0.0);
+3220            assert_eq!(plan.base_seconds, 0.0);
+3221            assert_eq!(plan.seek_to, None);
+3222        }
+3223    }
+3224
+3225    /// A downloaded item must resolve to its file, and a `downloads` row whose
+3226    /// file has gone must resolve to `None` so the caller falls back to
+3227    /// streaming instead of handing the player a path that cannot be opened.
+3228    ///
+3229    /// TRACES: UR-071 | DR-123 | UT-116
+3230    #[tokio::test]
+3231    async fn test_resolve_local_media_path() {
+3232        use super::resolve_local_media_path;
+3233        use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
+3234        use rusqlite::Connection;
+3235        use std::sync::{Arc, Mutex};
+3236
+3237        let conn = Connection::open_in_memory().unwrap();
+3238        conn.execute(
+3239            "CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
+3240            [],
+3241        )
+3242        .unwrap();
+3243        let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
+3244
+3245        // A real file on disk, so the existence check passes.
+3246        let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
+3247        std::fs::write(&present, b"x").unwrap();
+3248        let present_str = present.to_string_lossy().to_string();
+3249
+3250        for (item, status, path) in [
+3251            ("downloaded", "completed", present_str.as_str()),
+3252            ("still-going", "downloading", present_str.as_str()),
+3253            (
+3254                "file-gone",
+3255                "completed",
+3256                "/nonexistent/jellytau/missing.mp4",
+3257            ),
+3258        ] {
+3259            db_service
+3260                .execute(Query::with_params(
+3261                    "INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
+3262                    vec![
+3263                        crate::storage::db_service::QueryParam::String(item.to_string()),
+3264                        crate::storage::db_service::QueryParam::String(status.to_string()),
+3265                        crate::storage::db_service::QueryParam::String(path.to_string()),
+3266                    ],
+3267                ))
+3268                .await
+3269                .unwrap();
+3270        }
+3271
+3272        assert_eq!(
+3273            resolve_local_media_path(&db_service, "downloaded")
+3274                .await
+3275                .unwrap()
+3276                .as_deref(),
+3277            Some(present_str.as_str()),
+3278            "a completed download with its file present must resolve"
+3279        );
+3280
+3281        assert_eq!(
+3282            resolve_local_media_path(&db_service, "still-going")
+3283                .await
+3284                .unwrap(),
+3285            None,
+3286            "an in-progress download is not playable from disk"
+3287        );
+3288
+3289        assert_eq!(
+3290            resolve_local_media_path(&db_service, "file-gone")
+3291                .await
+3292                .unwrap(),
+3293            None,
+3294            "a row whose file has gone must fall back to streaming, not hand over a dead path"
+3295        );
+3296
+3297        assert_eq!(
+3298            resolve_local_media_path(&db_service, "never-heard-of-it")
+3299                .await
+3300                .unwrap(),
+3301            None
+3302        );
+3303
+3304        let _ = std::fs::remove_file(&present);
+3305    }
+3306
+3307    /// Queue items enqueued as Remote must flip to Local once a completed
+3308    /// download exists on disk — this is what makes preloaded tracks (and
+3309    /// offline playback after a connection drop) actually use the cache.
+3310    #[tokio::test]
+3311    async fn test_refresh_queue_local_sources_switches_completed_downloads() {
+3312        use super::{refresh_queue_local_sources, DatabaseWrapper};
+3313        use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
+3314        use crate::storage::Database;
+3315        use std::sync::Mutex;
+3316
+3317        // A real file on disk for the completed download; a missing file for
+3318        // the second entry to prove nonexistent files are not switched.
+3319        let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
+3320        std::fs::create_dir_all(&dir).unwrap();
+3321        let existing = dir.join("track-a.mp3");
+3322        std::fs::write(&existing, b"audio").unwrap();
+3323        let missing = dir.join("track-b-missing.mp3");
+3324        let _ = std::fs::remove_file(&missing);
+3325
+3326        let database = Database::open_in_memory().unwrap();
+3327        {
+3328            let conn = database.connection();
+3329            let conn = conn.lock_safe();
+3330            conn.execute_batch(&format!(
+3331                r#"
+3332                INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
+3333                INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
+3334                INSERT INTO downloads (item_id, user_id, file_path, status)
+3335                    VALUES ('track-a', 'user1', '{}', 'completed');
+3336                INSERT INTO downloads (item_id, user_id, file_path, status)
+3337                    VALUES ('track-b', 'user1', '{}', 'completed');
+3338                "#,
+3339                existing.display(),
+3340                missing.display()
+3341            ))
+3342            .unwrap();
+3343        }
+3344        let db = DatabaseWrapper(Mutex::new(database));
+3345
+3346        let make_item = |id: &str| MediaItem {
+3347            // Audio and direct-URL items never negotiate a transport.
+3348            transport: None,
+3349            id: id.to_string(),
+3350            title: id.to_string(),
+3351            name: None,
+3352            artist: None,
+3353            album: None,
+3354            album_name: None,
+3355            album_id: None,
+3356            artist_items: None,
+3357            artists: None,
+3358            primary_image_tag: None,
+3359            image_id: None,
+3360            item_type: None,
+3361            playlist_id: None,
+3362            duration: None,
+3363            artwork_url: None,
+3364            media_type: MediaType::Audio,
+3365            source: MediaSource::Remote {
+3366                stream_url: format!("http://test/Audio/{}/stream", id),
+3367                jellyfin_item_id: id.to_string(),
+3368            },
+3369            video_codec: None,
+3370            needs_transcoding: false,
+3371            video_width: None,
+3372            video_height: None,
+3373            subtitles: vec![],
+3374            series_id: None,
+3375            server_id: None,
+3376        };
+3377
+3378        let controller = PlayerController::default();
+3379        controller
+3380            .set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
+3381            .unwrap();
+3382
+3383        let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
+3384        assert_eq!(switched, 1, "only the download whose file exists switches");
+3385
+3386        let queue = controller.queue();
+3387        let queue_lock = queue.lock_safe();
+3388        match &queue_lock.items()[0].source {
+3389            MediaSource::Local {
+3390                file_path,
+3391                jellyfin_item_id,
+3392            } => {
+3393                assert_eq!(file_path, &existing);
+3394                assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
+3395            }
+3396            other => panic!("track-a should be local, got {:?}", other),
+3397        }
+3398        assert!(
+3399            matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
+3400            "track-b's file is missing, it must stay remote"
+3401        );
+3402    }
+3403
+3404    /// Test track index finding in album
+3405    /// This reproduces the bug where clicking songs 1-5 always played song 13
+3406    #[test]
+3407    fn test_find_track_index_in_album() {
+3408        // Create mock album tracks
+3409        #[derive(Clone)]
+3410        struct MockTrack {
+3411            id: String,
+3412            name: String,
+3413            index_number: Option<i32>,
+3414        }
+3415
+3416        let mut tracks = [
+3417            MockTrack {
+3418                id: "track1".to_string(),
+3419                name: "Song 1".to_string(),
+3420                index_number: Some(1),
+3421            },
+3422            MockTrack {
+3423                id: "track2".to_string(),
+3424                name: "Song 2".to_string(),
+3425                index_number: Some(2),
+3426            },
+3427            MockTrack {
+3428                id: "track3".to_string(),
+3429                name: "Song 3".to_string(),
+3430                index_number: Some(3),
+3431            },
+3432            MockTrack {
+3433                id: "track4".to_string(),
+3434                name: "Song 4".to_string(),
+3435                index_number: Some(4),
+3436            },
+3437            MockTrack {
+3438                id: "track5".to_string(),
+3439                name: "Song 5".to_string(),
+3440                index_number: Some(5),
+3441            },
+3442        ];
+3443
+3444        // Sort by index (same as the real code does)
+3445        tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
+3446
+3447        // Test finding track 1 (should be index 0)
+3448        let index1 = tracks.iter().position(|t| t.id == "track1");
+3449        assert_eq!(index1, Some(0), "Track 1 should be at index 0");
+3450        assert_eq!(
+3451            tracks[0].name, "Song 1",
+3452            "Track at index 0 should carry its name"
+3453        );
+3454
+3455        // Test finding track 3 (should be index 2)
+3456        let index3 = tracks.iter().position(|t| t.id == "track3");
+3457        assert_eq!(index3, Some(2), "Track 3 should be at index 2");
+3458
+3459        // Test finding track 5 (should be index 4)
+3460        let index5 = tracks.iter().position(|t| t.id == "track5");
+3461        assert_eq!(index5, Some(4), "Track 5 should be at index 4");
+3462
+3463        // Test finding non-existent track
+3464        let index_none = tracks.iter().position(|t| t.id == "nonexistent");
+3465        assert_eq!(index_none, None, "Non-existent track should return None");
+3466    }
+3467
+3468    /// Test that track order is preserved when iterating
+3469    #[test]
+3470    fn test_track_iteration_order() {
+3471        // Simulate the loop that builds MediaItems
+3472        let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];
+3473
+3474        // Find where "id3" is in the original list
+3475        let target_index = track_ids.iter().position(|&id| id == "id3");
+3476        assert_eq!(
+3477            target_index,
+3478            Some(2),
+3479            "id3 should be at index 2 in original list"
+3480        );
+3481
+3482        // Simulate building the MediaItems vector
+3483        let mut media_items = Vec::new();
+3484        for id in &track_ids {
+3485            media_items.push(id.to_string());
+3486        }
+3487
+3488        // Verify the order is preserved
+3489        assert_eq!(media_items.len(), 5);
+3490        assert_eq!(media_items[0], "id1");
+3491        assert_eq!(media_items[2], "id3");
+3492        assert_eq!(media_items[4], "id5");
+3493
+3494        // The start_index found earlier should still be valid
+3495        assert_eq!(media_items[target_index.unwrap()], "id3");
+3496    }
+3497
+3498    /// Test album track sorting behavior
+3499    #[test]
+3500    fn test_album_track_sorting() {
+3501        #[derive(Clone, Debug)]
+3502        struct MockTrack {
+3503            id: String,
+3504            name: String,
+3505            index_number: Option<i32>,
+3506        }
+3507
+3508        // Create tracks in random order (not sorted)
+3509        let mut tracks = [
+3510            MockTrack {
+3511                id: "id5".to_string(),
+3512                name: "Track 5".to_string(),
+3513                index_number: Some(5),
+3514            },
+3515            MockTrack {
+3516                id: "id1".to_string(),
+3517                name: "Track 1".to_string(),
+3518                index_number: Some(1),
+3519            },
+3520            MockTrack {
+3521                id: "id3".to_string(),
+3522                name: "Track 3".to_string(),
+3523                index_number: Some(3),
+3524            },
+3525            MockTrack {
+3526                id: "id2".to_string(),
+3527                name: "Track 2".to_string(),
+3528                index_number: Some(2),
+3529            },
+3530            MockTrack {
+3531                id: "id4".to_string(),
+3532                name: "Track 4".to_string(),
+3533                index_number: Some(4),
+3534            },
+3535        ];
+3536
+3537        // User clicks track "id1" before sorting - what index is it?
+3538        let requested_track_id = "id1";
+3539
+3540        // Sort the tracks (same as real code)
+3541        tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
+3542
+3543        // Now find the index AFTER sorting
+3544        let start_index = tracks.iter().position(|t| t.id == requested_track_id);
+3545
+3546        // Track "id1" should be at index 0 after sorting
+3547        assert_eq!(
+3548            start_index,
+3549            Some(0),
+3550            "After sorting, track id1 should be at index 0"
+3551        );
+3552
+3553        // Verify all tracks are in correct order
+3554        assert_eq!(tracks[0].id, "id1");
+3555        assert_eq!(
+3556            tracks[0].name, "Track 1",
+3557            "Sorted track should retain its name"
+3558        );
+3559        assert_eq!(tracks[1].id, "id2");
+3560        assert_eq!(tracks[2].id, "id3");
+3561        assert_eq!(tracks[3].id, "id4");
+3562        assert_eq!(tracks[4].id, "id5");
+3563    }
+3564}
+
\ 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..b43ec9cf --- /dev/null +++ b/api/src/jellytau_lib/commands/player/queue.rs.html @@ -0,0 +1,420 @@ +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        // Audio and direct-URL items never negotiate a transport.
+202        transport: None,
+203        id: track.id.clone(),
+204        title: track.name.clone(),
+205        name: Some(track.name.clone()), // Frontend compatibility
+206        artist: track
+207            .album_artist
+208            .clone()
+209            .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
+210        album: track.album_name.clone(),
+211        album_name: track.album_name.clone(), // Frontend compatibility
+212        album_id: track.album_id.clone(),
+213        artist_items: track.artist_items.clone(), // For clickable artist links
+214        artists: track.artists.clone(),           // Fallback artist info
+215        primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
+216        image_id: track.primary_image_tag.clone(),
+217        item_type: Some(track.item_type.clone()), // Frontend compatibility
+218        playlist_id: None,
+219        duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
+220        artwork_url: primary_image_tag_for_url.and_then(|tag| {
+221            track.album_id.as_ref().map(|album_id| {
+222                repository.get_image_url(
+223                    album_id,
+224                    ImageType::Primary,
+225                    Some(ImageOptions {
+226                        max_width: Some(300),
+227                        tag: Some(tag),
+228                        ..Default::default()
+229                    }),
+230                )
+231            })
+232        }),
+233        media_type: MediaType::Audio,
+234        source,
+235        video_codec: None,
+236        needs_transcoding: false,
+237        video_width: None,
+238        video_height: None,
+239        subtitles: vec![],
+240        series_id: None,
+241        server_id: None,
+242    };
+243
+244    // Add to queue at specified position
+245    let position = match request.position.as_str() {
+246        "next" => AddPosition::Next,
+247        _ => AddPosition::End,
+248    };
+249
+250    let controller = player.0.lock().await;
+251    let queue = controller.queue();
+252    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
+253
+254    queue_lock.add(vec![media_item], position);
+255
+256    let result = get_queue_status(&controller);
+257    drop(queue_lock);
+258    controller.emit_queue_changed();
+259
+260    info!("Successfully added track {} to queue", request.track_id);
+261    Ok(result)
+262}
+263
+264/// Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs
+265#[tauri::command]
+266#[specta::specta]
+267pub async fn player_add_tracks_by_ids(
+268    player: State<'_, PlayerStateWrapper>,
+269    db: State<'_, DatabaseWrapper>,
+270    repository_manager: State<'_, RepositoryManagerWrapper>,
+271    repository_handle: String,
+272    request: AddTracksByIdsRequest,
+273) -> Result<QueueStatus, String> {
+274    use crate::player::queue::AddPosition;
+275
+276    info!(
+277        "player_add_tracks_by_ids called: {} tracks, position={}",
+278        request.track_ids.len(),
+279        request.position
+280    );
+281
+282    // Get repository (hybrid - supports offline/online)
+283    let repository = repository_manager
+284        .0
+285        .get(&repository_handle)
+286        .ok_or("Repository not found - user may need to log in")?;
+287
+288    // Fetch metadata and build MediaItems for all tracks
+289    let mut media_items = Vec::new();
+290    for track_id in &request.track_ids {
+291        info!("Fetching metadata for track {} via repository", track_id);
+292        let track = repository
+293            .get_item(track_id)
+294            .await
+295            .map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
+296
+297        // Check for local download first
+298        let local_path = check_for_local_download(&db, track_id).await?;
+299
+300        let source = if let Some(path) = local_path {
+301            info!("Using local download for track {}", track_id);
+302            MediaSource::Local {
+303                file_path: PathBuf::from(path),
+304                jellyfin_item_id: Some(track.id.clone()),
+305            }
+306        } else {
+307            // Get stream URL from repository (works online/offline)
+308            let stream_url = repository
+309                .get_audio_stream_url(&track.id)
+310                .await
+311                .map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
+312
+313            MediaSource::Remote {
+314                stream_url,
+315                jellyfin_item_id: track.id.clone(),
+316            }
+317        };
+318
+319        // Build MediaItem with artwork URL from repository and frontend-compatible fields
+320        let primary_image_tag_for_url = track.primary_image_tag.clone();
+321        let media_item = MediaItem {
+322            // Audio and direct-URL items never negotiate a transport.
+323            transport: None,
+324            id: track.id.clone(),
+325            title: track.name.clone(),
+326            name: Some(track.name.clone()), // Frontend compatibility
+327            artist: track
+328                .album_artist
+329                .clone()
+330                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
+331            album: track.album_name.clone(),
+332            album_name: track.album_name.clone(), // Frontend compatibility
+333            album_id: track.album_id.clone(),
+334            artist_items: track.artist_items.clone(), // For clickable artist links
+335            artists: track.artists.clone(),           // Fallback artist info
+336            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
+337            image_id: track.primary_image_tag.clone(),
+338            item_type: Some(track.item_type.clone()), // Frontend compatibility
+339            playlist_id: None,
+340            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
+341            artwork_url: primary_image_tag_for_url.and_then(|tag| {
+342                track.album_id.as_ref().map(|album_id| {
+343                    repository.get_image_url(
+344                        album_id,
+345                        ImageType::Primary,
+346                        Some(ImageOptions {
+347                            max_width: Some(300),
+348                            tag: Some(tag),
+349                            ..Default::default()
+350                        }),
+351                    )
+352                })
+353            }),
+354            media_type: MediaType::Audio,
+355            source,
+356            video_codec: None,
+357            needs_transcoding: false,
+358            video_width: None,
+359            video_height: None,
+360            subtitles: vec![],
+361            series_id: None,
+362            server_id: None,
+363        };
+364
+365        media_items.push(media_item);
+366    }
+367
+368    // Add to queue at specified position
+369    let position = match request.position.as_str() {
+370        "next" => AddPosition::Next,
+371        _ => AddPosition::End,
+372    };
+373
+374    let controller = player.0.lock().await;
+375    let queue = controller.queue();
+376    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
+377
+378    queue_lock.add(media_items, position);
+379
+380    let result = get_queue_status(&controller);
+381    drop(queue_lock);
+382    controller.emit_queue_changed();
+383
+384    info!(
+385        "Successfully added {} tracks to queue",
+386        request.track_ids.len()
+387    );
+388    Ok(result)
+389}
+390
+391#[tauri::command]
+392#[specta::specta]
+393pub async fn player_skip_to(
+394    player: State<'_, PlayerStateWrapper>,
+395    db: State<'_, DatabaseWrapper>,
+396    index: usize,
+397) -> Result<PlayerStatus, String> {
+398    let controller = player.0.lock().await;
+399
+400    // Prefer downloads that completed since the queue was built
+401    if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await {
+402        log::warn!("[player_skip_to] Failed to refresh local sources: {}", e);
+403    }
+404
+405    // Skip to the index and get the item to play
+406    let item = {
+407        let queue = controller.queue();
+408        let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
+409        queue_lock.skip_to(index).cloned().ok_or("Invalid index")?
+410    };
+411
+412    // Play the item without modifying the queue
+413    controller.load_and_play(&item).map_err(|e| e.to_string())?;
+414
+415    // Emit queue changed event
+416    controller.emit_queue_changed();
+417
+418    Ok(get_player_status(&controller))
+419}
+
\ 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..1d0d7164 --- /dev/null +++ b/api/src/jellytau_lib/commands/repository.rs.html @@ -0,0 +1,1219 @@ +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, StreamSelection,
+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/// Decide what stream to play for a video, and describe it.
+610///
+611/// Replaces `repository_get_video_stream_url` for playback. The returned
+612/// [`StreamSelection`] carries the transport explicitly, so the frontend picks
+613/// its loader from a tagged enum instead of testing the URL for `.m3u8`; and it
+614/// carries the quality ladder as it applies to *this* source, so the picker can
+615/// stop offering rungs that produce the same bytes as Original.
+616///
+617/// No start-position parameter, for the same reason as the URL builder: a
+618/// position on an HLS playlist is copied onto every segment URI and the server
+619/// rejects each with `400` (DR-181). Callers resume by seeking after load.
+620///
+621/// TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
+622#[tauri::command]
+623#[specta::specta]
+624pub async fn repository_get_stream_selection(
+625    manager: State<'_, RepositoryManagerWrapper>,
+626    handle: String,
+627    item_id: String,
+628    media_source_id: Option<String>,
+629    audio_stream_index: Option<i32>,
+630) -> Result<StreamSelection, String> {
+631    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+632    repo.as_ref()
+633        .get_stream_selection(&item_id, media_source_id.as_deref(), audio_stream_index)
+634        .await
+635        .map_err(|e| format!("{:?}", e))
+636}
+637
+638/// Get an audio-only stream URL for a *video* item (background-audio handoff).
+639///
+640/// TRACES: UR-040 | JA-032 | UT-061
+641#[tauri::command]
+642#[specta::specta]
+643pub async fn repository_get_audio_only_stream_url_for_video(
+644    manager: State<'_, RepositoryManagerWrapper>,
+645    handle: String,
+646    item_id: String,
+647    media_source_id: Option<String>,
+648    start_time_seconds: Option<f64>,
+649    audio_stream_index: Option<i32>,
+650) -> Result<String, String> {
+651    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+652    repo.as_ref()
+653        .get_audio_only_stream_url_for_video(
+654            &item_id,
+655            media_source_id.as_deref(),
+656            start_time_seconds,
+657            audio_stream_index,
+658        )
+659        .await
+660        .map_err(|e| format!("{:?}", e))
+661}
+662
+663/// Get audio stream URL for a track
+664#[tauri::command]
+665#[specta::specta]
+666pub async fn repository_get_audio_stream_url(
+667    manager: State<'_, RepositoryManagerWrapper>,
+668    handle: String,
+669    item_id: String,
+670) -> Result<String, String> {
+671    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+672    repo.as_ref()
+673        .get_audio_stream_url(&item_id)
+674        .await
+675        .map_err(|e| format!("{:?}", e))
+676}
+677
+678/// Get Live TV channels (broadcast / IPTV) for browsing
+679#[tauri::command]
+680#[specta::specta]
+681pub async fn repository_get_live_tv_channels(
+682    manager: State<'_, RepositoryManagerWrapper>,
+683    handle: String,
+684) -> Result<Vec<MediaItem>, String> {
+685    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+686    repo.as_ref()
+687        .get_live_tv_channels()
+688        .await
+689        .map_err(|e| format!("{:?}", e))
+690}
+691
+692/// Get the root list of plugin "Channels"
+693#[tauri::command]
+694#[specta::specta]
+695pub async fn repository_get_channels(
+696    manager: State<'_, RepositoryManagerWrapper>,
+697    handle: String,
+698) -> Result<SearchResult, String> {
+699    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+700    repo.as_ref()
+701        .get_channels()
+702        .await
+703        .map_err(|e| format!("{:?}", e))
+704}
+705
+706/// Open a live stream for a Live TV channel / live item
+707#[tauri::command]
+708#[specta::specta]
+709pub async fn repository_open_live_stream(
+710    manager: State<'_, RepositoryManagerWrapper>,
+711    handle: String,
+712    item_id: String,
+713) -> Result<LiveStreamInfo, String> {
+714    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+715    repo.as_ref()
+716        .open_live_stream(&item_id)
+717        .await
+718        .map_err(|e| format!("{:?}", e))
+719}
+720
+721/// Report playback start
+722#[tauri::command]
+723#[specta::specta]
+724pub async fn repository_report_playback_start(
+725    manager: State<'_, RepositoryManagerWrapper>,
+726    handle: String,
+727    item_id: String,
+728    position_ms: i64,
+729) -> Result<(), String> {
+730    let position_ticks = position_ms * 10_000;
+731    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+732    repo.as_ref()
+733        .report_playback_start(&item_id, position_ticks)
+734        .await
+735        .map_err(|e| format!("{:?}", e))
+736}
+737
+738/// Report playback progress
+739#[tauri::command]
+740#[specta::specta]
+741pub async fn repository_report_playback_progress(
+742    manager: State<'_, RepositoryManagerWrapper>,
+743    handle: String,
+744    item_id: String,
+745    position_ms: i64,
+746) -> Result<(), String> {
+747    let position_ticks = position_ms * 10_000;
+748    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+749    repo.as_ref()
+750        .report_playback_progress(&item_id, position_ticks)
+751        .await
+752        .map_err(|e| format!("{:?}", e))
+753}
+754
+755/// Report playback stopped
+756///
+757/// A stop-report that cannot reach the server is queued rather than dropped:
+758/// this is the position the resume point is built from, and losing it is
+759/// exactly the "it forgot where I was" the sync queue exists to prevent. The
+760/// drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
+761/// failing the command because the *queue* write failed would tell the caller
+762/// the report was lost when the local position was already saved.
+763///
+764/// TRACES: UR-025 | DR-154 | UT-151
+765#[tauri::command]
+766#[specta::specta]
+767pub async fn repository_report_playback_stopped(
+768    db: State<'_, crate::commands::storage::DatabaseWrapper>,
+769    manager: State<'_, RepositoryManagerWrapper>,
+770    handle: String,
+771    item_id: String,
+772    position_ms: i64,
+773) -> Result<(), String> {
+774    // Milliseconds across the boundary; the Jellyfin API wants ticks.
+775    let position_ticks = position_ms * 10_000;
+776    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+777
+778    let result = repo
+779        .as_ref()
+780        .report_playback_stopped(&item_id, position_ticks)
+781        .await;
+782
+783    if let Err(e) = &result {
+784        let db_service = {
+785            let database = db.0.lock().map_err(|err| err.to_string())?;
+786            Arc::new(database.service())
+787        };
+788        let user_id = repo.user_id().to_string();
+789        if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
+790            &db_service,
+791            &user_id,
+792            &item_id,
+793            position_ticks,
+794        )
+795        .await
+796        {
+797            warn!(
+798                "[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
+799                item_id, e, queue_err
+800            );
+801        } else {
+802            debug!(
+803                "[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
+804                item_id, e
+805            );
+806        }
+807    }
+808
+809    result.map_err(|e| format!("{:?}", e))
+810}
+811
+812/// Get image URL for an item
+813#[tauri::command]
+814#[specta::specta]
+815pub fn repository_get_image_url(
+816    manager: State<'_, RepositoryManagerWrapper>,
+817    handle: String,
+818    item_id: String,
+819    image_type: ImageType,
+820    options: Option<ImageOptions>,
+821) -> Result<String, String> {
+822    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+823    Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
+824}
+825
+826/// Get subtitle URL for a media item
+827#[tauri::command]
+828#[specta::specta]
+829#[allow(dead_code)]
+830pub fn repository_get_subtitle_url(
+831    manager: State<'_, RepositoryManagerWrapper>,
+832    handle: String,
+833    item_id: String,
+834    media_source_id: String,
+835    stream_index: i32,
+836    format: String,
+837) -> Result<String, String> {
+838    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+839    Ok(repo
+840        .as_ref()
+841        .get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
+842}
+843
+844/// Get video download URL with quality preset
+845#[tauri::command]
+846#[specta::specta]
+847#[allow(dead_code)]
+848pub async fn repository_get_video_download_url(
+849    manager: State<'_, RepositoryManagerWrapper>,
+850    handle: String,
+851    item_id: String,
+852    quality: String,
+853    media_source_id: Option<String>,
+854) -> Result<String, String> {
+855    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+856    // Async because the audio-codec policy has to know what the source's audio
+857    // is before it can decide whether the file may be copied verbatim (DR-171).
+858    // The frontend calls this exactly as before — the decision stays in Rust.
+859    Ok(crate::repository::resolve_video_download_url(
+860        repo.as_ref(),
+861        &item_id,
+862        &quality,
+863        media_source_id.as_deref(),
+864    )
+865    .await)
+866}
+867
+868/// Mark an item as favorite
+869#[tauri::command]
+870#[specta::specta]
+871pub async fn repository_mark_favorite(
+872    manager: State<'_, RepositoryManagerWrapper>,
+873    handle: String,
+874    item_id: String,
+875) -> Result<(), String> {
+876    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+877    repo.as_ref()
+878        .mark_favorite(&item_id)
+879        .await
+880        .map_err(|e| format!("{:?}", e))
+881}
+882
+883/// Tauri event announcing that favourite state changed behind the UI's back —
+884/// either because the server disagreed with the cache on a background refresh,
+885/// or because pending offline toggles were pushed on reconnect.
+886///
+887/// TRACES: UR-069 | DR-120
+888pub const FAVORITES_CHANGED_EVENT: &str = "favorites-changed";
+889
+890/// Payload for [`FAVORITES_CHANGED_EVENT`] — the ids whose favourite state
+891/// actually flipped, so the frontend refreshes those rather than everything.
+892///
+893/// TRACES: UR-069 | DR-120 | UT-107
+894#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+895#[serde(rename_all = "camelCase")]
+896pub struct FavoritesChangedEvent {
+897    pub item_ids: Vec<String>,
+898}
+899
+900/// Ids whose favourite state differs between what we showed and what the server
+901/// has — favourited elsewhere since the cache was written, or un-favourited
+902/// elsewhere.
+903///
+904/// Pulled out of the command so the "emit nothing when nothing changed" rule is
+905/// testable: an unchanged set must leave a quiet page quiet rather than
+906/// triggering a refetch on every visit.
+907///
+908/// TRACES: UR-069 | DR-120 | UT-107
+909fn changed_favorite_ids(
+910    cached: &std::collections::HashSet<String>,
+911    server: &std::collections::HashSet<String>,
+912) -> Vec<String> {
+913    let mut changed: Vec<String> = server.symmetric_difference(cached).cloned().collect();
+914    // Deterministic order so the event payload does not depend on hash seeding.
+915    changed.sort();
+916    changed
+917}
+918
+919/// Everything the viewer has favourited, across libraries, narrowed by scope.
+920///
+921/// Two-phase like `repository_search`: the local answer returns immediately and
+922/// a background server pass emits `favorites-changed` when the server's set
+923/// differs. Without the second phase a favourite marked in another client shows
+924/// up only on the *second* visit to the page, since the cache-first read hands
+925/// back local rows and the refresh is invisible to the frontend.
+926///
+927/// TRACES: UR-067 | DR-115, DR-120, JA-033 | UT-107
+928#[tauri::command]
+929#[specta::specta]
+930pub async fn repository_get_favorites(
+931    app: AppHandle,
+932    manager: State<'_, RepositoryManagerWrapper>,
+933    handle: String,
+934    scope: SearchScope,
+935    options: Option<GetItemsOptions>,
+936) -> Result<SearchResult, String> {
+937    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+938
+939    let cache_result = repo
+940        .get_favorites_cache_only(scope, options.clone())
+941        .await
+942        .unwrap_or_else(|e| {
+943            debug!("[Favorites] Cache miss/timeout: {:?}", e);
+944            SearchResult {
+945                items: Vec::new(),
+946                total_record_count: 0,
+947            }
+948        });
+949
+950    // With "Show all server media" off the local answer is authoritative
+951    // (DR-080) — don't go behind the user's back to the server.
+952    if !crate::repository::offline::include_catalog_browse() {
+953        return Ok(cache_result);
+954    }
+955
+956    // Nothing cached yet — a fresh install, or a viewer whose favourites were
+957    // all marked on another client. Returning the empty result here paints
+958    // "Nothing favourited yet — tap the heart on anything you like", which is a
+959    // *wrong* answer, corrected a server round trip later when the background
+960    // refresh fires `favorites-changed`. Ask the repository for a real answer
+961    // instead: its `get_favorites` is exactly this read — cache first, server on
+962    // a miss, saving through — and it applies the same DR-080 gate.
+963    //
+964    // TRACES: UR-067 | DR-115
+965    if !cache_result.has_content() {
+966        debug!("[Favorites] Nothing cached; answering from the server");
+967        return repo
+968            .get_favorites(scope, options)
+969            .await
+970            .map_err(|e| format!("{:?}", e));
+971    }
+972
+973    let repo_bg = repo.clone();
+974    let cached_ids: std::collections::HashSet<String> =
+975        cache_result.items.iter().map(|i| i.id.clone()).collect();
+976    tauri::async_runtime::spawn(async move {
+977        match repo_bg.get_favorites_server_only(scope, options).await {
+978            Ok(server_result) => {
+979                let server_ids: std::collections::HashSet<String> =
+980                    server_result.items.iter().map(|i| i.id.clone()).collect();
+981                let changed = changed_favorite_ids(&cached_ids, &server_ids);
+982
+983                if !changed.is_empty() {
+984                    let event = FavoritesChangedEvent { item_ids: changed };
+985                    if let Err(e) = app.emit(FAVORITES_CHANGED_EVENT, &event) {
+986                        error!("[Favorites] Failed to emit change event: {}", e);
+987                    }
+988                }
+989            }
+990            Err(e) => {
+991                warn!(
+992                    "[Favorites] Server refresh failed, keeping cached favourites: {:?}",
+993                    e
+994                );
+995            }
+996        }
+997    });
+998
+999    Ok(cache_result)
+1000}
+1001
+1002/// Unmark an item as favorite
+1003#[tauri::command]
+1004#[specta::specta]
+1005pub async fn repository_unmark_favorite(
+1006    manager: State<'_, RepositoryManagerWrapper>,
+1007    handle: String,
+1008    item_id: String,
+1009) -> Result<(), String> {
+1010    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+1011    repo.as_ref()
+1012        .unmark_favorite(&item_id)
+1013        .await
+1014        .map_err(|e| format!("{:?}", e))
+1015}
+1016
+1017/// Get person details
+1018#[tauri::command]
+1019#[specta::specta]
+1020pub async fn repository_get_person(
+1021    manager: State<'_, RepositoryManagerWrapper>,
+1022    handle: String,
+1023    person_id: String,
+1024) -> Result<MediaItem, String> {
+1025    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+1026    repo.as_ref()
+1027        .get_person(&person_id)
+1028        .await
+1029        .map_err(|e| format!("{:?}", e))
+1030}
+1031
+1032/// Get items by person (actor, director, etc.)
+1033#[tauri::command]
+1034#[specta::specta]
+1035pub async fn repository_get_items_by_person(
+1036    manager: State<'_, RepositoryManagerWrapper>,
+1037    handle: String,
+1038    person_id: String,
+1039    options: Option<GetItemsOptions>,
+1040) -> Result<SearchResult, String> {
+1041    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+1042    repo.as_ref()
+1043        .get_items_by_person(&person_id, options)
+1044        .await
+1045        .map_err(|e| format!("{:?}", e))
+1046}
+1047
+1048/// Get similar/related items for a media item
+1049#[tauri::command]
+1050#[specta::specta]
+1051pub async fn repository_get_similar_items(
+1052    manager: State<'_, RepositoryManagerWrapper>,
+1053    handle: String,
+1054    item_id: String,
+1055    limit: Option<usize>,
+1056) -> Result<SearchResult, String> {
+1057    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+1058    repo.as_ref()
+1059        .get_similar_items(&item_id, limit)
+1060        .await
+1061        .map_err(|e| format!("{:?}", e))
+1062}
+1063
+1064#[cfg(test)]
+1065mod tests {
+1066    use super::*;
+1067
+1068    #[test]
+1069    fn test_repository_manager_creation() {
+1070        let manager = RepositoryManager::new();
+1071        // A freshly created manager holds no repositories
+1072        assert!(manager.get("any-handle").is_none());
+1073    }
+1074
+1075    fn ids(values: &[&str]) -> std::collections::HashSet<String> {
+1076        values.iter().map(|v| v.to_string()).collect()
+1077    }
+1078
+1079    /// UT-107 — the background refresh reports only what actually changed.
+1080    ///
+1081    /// TRACES: UR-069 | DR-120 | UT-107
+1082    #[test]
+1083    fn test_changed_favorite_ids_reports_both_directions() {
+1084        // Favourited in another client since we cached.
+1085        assert_eq!(
+1086            changed_favorite_ids(&ids(&["a"]), &ids(&["a", "b"])),
+1087            vec!["b".to_string()]
+1088        );
+1089
+1090        // Un-favourited in another client.
+1091        assert_eq!(
+1092            changed_favorite_ids(&ids(&["a", "b"]), &ids(&["a"])),
+1093            vec!["b".to_string()]
+1094        );
+1095
+1096        // Both at once, in a stable order.
+1097        assert_eq!(
+1098            changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "c"])),
+1099            vec!["a".to_string(), "c".to_string()]
+1100        );
+1101    }
+1102
+1103    /// An unchanged set emits nothing — otherwise every visit to the page would
+1104    /// fire an event and trigger a pointless refetch.
+1105    ///
+1106    /// TRACES: UR-069 | DR-120 | UT-107
+1107    #[test]
+1108    fn test_changed_favorite_ids_is_empty_when_nothing_moved() {
+1109        assert!(changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "a"])).is_empty());
+1110        assert!(changed_favorite_ids(&ids(&[]), &ids(&[])).is_empty());
+1111    }
+1112
+1113    #[test]
+1114    fn test_repository_manager_wrapper_structure() {
+1115        let manager = RepositoryManager::new();
+1116        let wrapper = RepositoryManagerWrapper(manager);
+1117        // The wrapper exposes the underlying manager, which starts empty
+1118        assert!(wrapper.0.get("any-handle").is_none());
+1119    }
+1120
+1121    #[test]
+1122    fn test_repository_manager_get_nonexistent() {
+1123        let manager = RepositoryManager::new();
+1124        // Getting a non-existent repository should return None
+1125        let result = manager.get("nonexistent-handle");
+1126        assert!(result.is_none());
+1127    }
+1128
+1129    #[test]
+1130    fn test_uuid_handle_generation() {
+1131        let uuid = Uuid::new_v4();
+1132        let handle = format!("{}", uuid);
+1133        // UUID should convert to a non-empty string
+1134        assert!(!handle.is_empty());
+1135    }
+1136
+1137    #[test]
+1138    fn test_uuid_handles_are_unique() {
+1139        let handle1 = format!("{}", Uuid::new_v4());
+1140        let handle2 = format!("{}", Uuid::new_v4());
+1141        // Two generated UUIDs should be different
+1142        assert_ne!(handle1, handle2);
+1143    }
+1144
+1145    #[test]
+1146    fn test_uuid_handle_format() {
+1147        let uuid = Uuid::new_v4();
+1148        let handle = format!("{}", uuid);
+1149        // UUID should have standard format with hyphens
+1150        let parts: Vec<&str> = handle.split('-').collect();
+1151        assert_eq!(parts.len(), 5);
+1152    }
+1153
+1154    #[test]
+1155    fn test_repository_manager_destroy_nonexistent() {
+1156        let manager = RepositoryManager::new();
+1157        // Destroying a non-existent repository should not panic
+1158        manager.destroy("nonexistent-handle");
+1159    }
+1160
+1161    #[test]
+1162    fn test_repository_manager_is_send_sync() {
+1163        // Verify RepositoryManager can be used in async contexts
+1164        fn is_send_sync<T: Send + Sync>() {}
+1165        is_send_sync::<RepositoryManager>();
+1166    }
+1167
+1168    #[test]
+1169    fn test_repository_manager_wrapper_is_send_sync() {
+1170        // Verify RepositoryManagerWrapper is Send + Sync
+1171        fn is_send_sync<T: Send + Sync>() {}
+1172        is_send_sync::<RepositoryManagerWrapper>();
+1173    }
+1174
+1175    #[test]
+1176    fn test_multiple_manager_instances() {
+1177        let manager1 = RepositoryManager::new();
+1178        let manager2 = RepositoryManager::new();
+1179
+1180        // Multiple manager instances should be independent
+1181        let handle1_nonexistent = manager1.get("test");
+1182        let handle2_nonexistent = manager2.get("test");
+1183
+1184        assert!(handle1_nonexistent.is_none());
+1185        assert!(handle2_nonexistent.is_none());
+1186    }
+1187
+1188    #[test]
+1189    fn test_handle_string_properties() {
+1190        let uuid = Uuid::new_v4();
+1191        let handle = format!("{}", uuid);
+1192
+1193        // Handle should be alphanumeric with hyphens
+1194        for c in handle.chars() {
+1195            assert!(c.is_alphanumeric() || c == '-');
+1196        }
+1197    }
+1198
+1199    #[test]
+1200    fn test_repository_manager_concurrent_access() {
+1201        let manager = Arc::new(RepositoryManager::new());
+1202        let mut handles = vec![];
+1203
+1204        // Verify manager can be wrapped in Arc for concurrent access
+1205        for _ in 0..3 {
+1206            let mgr = Arc::clone(&manager);
+1207            let handle = std::thread::spawn(move || {
+1208                let result = mgr.get("test");
+1209                assert!(result.is_none());
+1210            });
+1211            handles.push(handle);
+1212        }
+1213
+1214        for h in handles {
+1215            h.join().unwrap();
+1216        }
+1217    }
+1218}
+
\ 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..fe6903f4 --- /dev/null +++ b/api/src/jellytau_lib/commands/storage/mod.rs.html @@ -0,0 +1,1754 @@ +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/// The stream selection for a downloaded file.
+108///
+109/// The local-playback counterpart to `repository_get_stream_selection`. A file
+110/// on disk needs no negotiation — it is a direct play over a local transport,
+111/// with no quality ladder, because nothing about it can be re-negotiated — but
+112/// the *frontend must not be the one to say so*. It gets the same
+113/// [`StreamSelection`] shape as a streamed source so the player has one contract
+114/// to consume rather than two, and so no caller has to infer a transport from a
+115/// loopback URL.
+116///
+117/// TRACES: UR-071, UR-079 | DR-225
+118#[tauri::command]
+119#[specta::specta]
+120pub fn media_local_selection(
+121    server: State<crate::media_server::MediaServerWrapper>,
+122    path: String,
+123) -> Result<crate::repository::StreamSelection, String> {
+124    server
+125        .0
+126        .as_ref()
+127        .map(|s| crate::repository::StreamSelection::local_file(s.url_for(&path)))
+128        .ok_or_else(|| "Local media server is not running".to_string())
+129}
+130
+131/// Get storage directory path (parent directory of the database file)
+132#[tauri::command]
+133#[specta::specta]
+134pub fn storage_get_path(db: State<DatabaseWrapper>) -> Result<String, String> {
+135    let database = db.0.lock().map_err(|e| e.to_string())?;
+136    let db_path = database.path();
+137
+138    // Return the parent directory instead of the database file path
+139    let storage_dir = db_path
+140        .parent()
+141        .ok_or_else(|| "Database path has no parent directory".to_string())?;
+142
+143    Ok(storage_dir.to_string_lossy().to_string())
+144}
+145
+146/// Get database file size in bytes
+147#[tauri::command]
+148#[specta::specta]
+149pub fn storage_get_size(db: State<DatabaseWrapper>) -> Result<Option<u64>, String> {
+150    let database = db.0.lock().map_err(|e| e.to_string())?;
+151    Ok(database.file_size())
+152}
+153
+154/// Get security status (keyring vs encrypted file fallback)
+155#[tauri::command]
+156#[specta::specta]
+157pub fn storage_get_security_status(
+158    creds: State<CredentialStoreWrapper>,
+159) -> Result<SecurityStatus, String> {
+160    let store = creds.0.lock().map_err(|e| e.to_string())?;
+161    let using_keyring = store.is_using_keyring();
+162    Ok(SecurityStatus {
+163        using_keyring,
+164        storage_type: if using_keyring {
+165            "system_keyring".to_string()
+166        } else {
+167            "encrypted_file".to_string()
+168        },
+169    })
+170}
+171
+172/// Save a server connection
+173/// Uses INSERT ... ON CONFLICT to avoid triggering CASCADE DELETE on users
+174#[tauri::command]
+175#[specta::specta]
+176pub async fn storage_save_server(
+177    db: State<'_, DatabaseWrapper>,
+178    id: String,
+179    name: String,
+180    url: String,
+181    version: Option<String>,
+182) -> Result<(), String> {
+183    let db_service = {
+184        let database = db.0.lock().map_err(|e| e.to_string())?;
+185        Arc::new(database.service())
+186    };
+187
+188    // IMPORTANT: Use ON CONFLICT ... DO UPDATE instead of INSERT OR REPLACE
+189    // INSERT OR REPLACE triggers DELETE + INSERT, which cascades to delete all users!
+190    let query = Query::with_params(
+191        "INSERT INTO servers (id, name, url, version, last_connected_at)
+192         VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
+193         ON CONFLICT(id) DO UPDATE SET
+194            name = excluded.name,
+195            url = excluded.url,
+196            version = excluded.version,
+197            last_connected_at = CURRENT_TIMESTAMP",
+198        vec![
+199            QueryParam::String(id),
+200            QueryParam::String(name),
+201            QueryParam::String(url),
+202            version.map(QueryParam::String).unwrap_or(QueryParam::Null),
+203        ],
+204    );
+205
+206    db_service.execute(query).await.map_err(|e| e.to_string())?;
+207
+208    Ok(())
+209}
+210
+211/// Get all saved servers
+212#[tauri::command]
+213#[specta::specta]
+214pub async fn storage_get_servers(
+215    db: State<'_, DatabaseWrapper>,
+216) -> Result<Vec<ServerInfo>, String> {
+217    let db_service = {
+218        let database = db.0.lock().map_err(|e| e.to_string())?;
+219        Arc::new(database.service())
+220    };
+221
+222    let query =
+223        Query::new("SELECT id, name, url, version FROM servers ORDER BY last_connected_at DESC");
+224
+225    let servers = db_service
+226        .query_many(query, |row| {
+227            Ok(ServerInfo {
+228                id: row.get(0)?,
+229                name: row.get(1)?,
+230                url: row.get(2)?,
+231                version: row.get(3)?,
+232            })
+233        })
+234        .await
+235        .map_err(|e| e.to_string())?;
+236
+237    Ok(servers)
+238}
+239
+240/// Delete a server and all associated data
+241#[tauri::command]
+242#[specta::specta]
+243pub async fn storage_delete_server(
+244    db: State<'_, DatabaseWrapper>,
+245    creds: State<'_, CredentialStoreWrapper>,
+246    server_id: String,
+247) -> Result<(), String> {
+248    let db_service = {
+249        let database = db.0.lock().map_err(|e| e.to_string())?;
+250        Arc::new(database.service())
+251    };
+252
+253    // Get all user IDs for this server to delete their tokens
+254    let user_query = Query::with_params(
+255        "SELECT id FROM users WHERE server_id = ?",
+256        vec![QueryParam::String(server_id.clone())],
+257    );
+258
+259    let user_ids: Vec<String> = db_service
+260        .query_many(user_query, |row| row.get(0))
+261        .await
+262        .map_err(|e| e.to_string())?;
+263
+264    // Delete tokens from secure storage
+265    {
+266        let store = creds.0.lock().map_err(|e| e.to_string())?;
+267        for user_id in user_ids {
+268            let _ = store.delete_token(&user_id); // Ignore errors, user might not have token
+269        }
+270    } // Drop store lock here
+271
+272    // Delete server (cascades to users via foreign key)
+273    let delete_query = Query::with_params(
+274        "DELETE FROM servers WHERE id = ?",
+275        vec![QueryParam::String(server_id)],
+276    );
+277
+278    db_service
+279        .execute(delete_query)
+280        .await
+281        .map_err(|e| e.to_string())?;
+282
+283    Ok(())
+284}
+285
+286/// Save a user account (token stored in secure storage, not database)
+287#[tauri::command]
+288#[specta::specta]
+289pub async fn storage_save_user(
+290    db: State<'_, DatabaseWrapper>,
+291    creds: State<'_, CredentialStoreWrapper>,
+292    id: String,
+293    server_id: String,
+294    username: String,
+295    access_token: Option<String>,
+296) -> Result<bool, String> {
+297    info!(
+298        "storage_save_user called: id={}, server_id={}, username={}",
+299        id, server_id, username
+300    );
+301
+302    let (db_service, db_path) = {
+303        let database = db.0.lock().map_err(|e| {
+304            error!("Failed to lock database: {}", e);
+305            e.to_string()
+306        })?;
+307        let path = database.path().to_path_buf();
+308        (Arc::new(database.service()), path)
+309    };
+310
+311    // Save user metadata to database (without token)
+312    // IMPORTANT: Use ON CONFLICT ... DO UPDATE instead of INSERT OR REPLACE
+313    // INSERT OR REPLACE triggers DELETE + INSERT, which cascades to delete user_data!
+314    debug!("Executing INSERT INTO users with ON CONFLICT...");
+315    let insert_query = Query::with_params(
+316        "INSERT INTO users (id, server_id, username, last_login_at)
+317         VALUES (?, ?, ?, CURRENT_TIMESTAMP)
+318         ON CONFLICT(id) DO UPDATE SET
+319            server_id = excluded.server_id,
+320            username = excluded.username,
+321            last_login_at = CURRENT_TIMESTAMP",
+322        vec![
+323            QueryParam::String(id.clone()),
+324            QueryParam::String(server_id),
+325            QueryParam::String(username),
+326        ],
+327    );
+328
+329    db_service.execute(insert_query).await.map_err(|e| {
+330        error!("Failed to save user: {}", e);
+331        e.to_string()
+332    })?;
+333    info!("User saved to database successfully");
+334
+335    // Verify the user was actually saved
+336    let verify_query = Query::with_params(
+337        "SELECT COUNT(*) FROM users WHERE id = ?",
+338        vec![QueryParam::String(id.clone())],
+339    );
+340    let verify_count: i32 = db_service
+341        .query_one(verify_query, |row| row.get(0))
+342        .await
+343        .unwrap_or(-1);
+344    debug!("VERIFY: {} users with id={} after insert", verify_count, id);
+345    debug!("Database path: {:?}", db_path);
+346
+347    // Force WAL checkpoint to ensure user data is persisted to disk
+348    let checkpoint_query = Query::new("PRAGMA wal_checkpoint(PASSIVE)");
+349    match db_service.execute(checkpoint_query).await {
+350        Ok(_) => debug!("WAL checkpoint completed after save_user"),
+351        Err(e) => warn!("WAL checkpoint failed: {}", e),
+352    }
+353
+354    // Save token to secure storage if provided
+355    let using_keyring = if let Some(token) = access_token {
+356        let store = creds.0.lock().map_err(|e| e.to_string())?;
+357        let result = store.save_token(&id, &token).map_err(|e| e.to_string())?;
+358        let is_keyring = matches!(result, crate::credentials::CredentialResult::Keyring);
+359        is_keyring
+360    } else {
+361        // No token provided, check current status
+362        let store = creds.0.lock().map_err(|e| e.to_string())?;
+363        store.is_using_keyring()
+364    };
+365
+366    // Return whether we're using the secure keyring
+367    Ok(using_keyring)
+368}
+369
+370/// Get users for a server
+371#[tauri::command]
+372#[specta::specta]
+373pub async fn storage_get_users(
+374    db: State<'_, DatabaseWrapper>,
+375    server_id: String,
+376) -> Result<Vec<UserInfo>, String> {
+377    let db_service = {
+378        let database = db.0.lock().map_err(|e| e.to_string())?;
+379        Arc::new(database.service())
+380    };
+381
+382    let query = Query::with_params(
+383        "SELECT id, server_id, username, is_active FROM users
+384         WHERE server_id = ? ORDER BY last_login_at DESC",
+385        vec![QueryParam::String(server_id)],
+386    );
+387
+388    let users = db_service
+389        .query_many(query, |row| {
+390            Ok(UserInfo {
+391                id: row.get(0)?,
+392                server_id: row.get(1)?,
+393                username: row.get(2)?,
+394                is_active: row.get::<_, i32>(3)? != 0,
+395            })
+396        })
+397        .await
+398        .map_err(|e| e.to_string())?;
+399
+400    Ok(users)
+401}
+402
+403/// Set a user as active (and deactivate all other users globally)
+404#[tauri::command]
+405#[specta::specta]
+406pub async fn storage_set_active_user(
+407    db: State<'_, DatabaseWrapper>,
+408    user_id: String,
+409    _server_id: String,
+410) -> Result<(), String> {
+411    info!("storage_set_active_user called: user_id={}", user_id);
+412
+413    let (db_service, db_path) = {
+414        let database = db.0.lock().map_err(|e| e.to_string())?;
+415        let path = database.path().to_path_buf();
+416        (Arc::new(database.service()), path)
+417    };
+418
+419    // Deactivate ALL users globally (since we only connect to one server at a time)
+420    let deactivate_query = Query::new("UPDATE users SET is_active = 0");
+421    db_service
+422        .execute(deactivate_query)
+423        .await
+424        .map_err(|e| e.to_string())?;
+425
+426    // Activate the specified user and update last_login_at
+427    let activate_query = Query::with_params(
+428        "UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
+429        vec![QueryParam::String(user_id.clone())],
+430    );
+431    let rows_affected = db_service
+432        .execute(activate_query)
+433        .await
+434        .map_err(|e| e.to_string())?;
+435
+436    debug!("storage_set_active_user: {} rows affected", rows_affected);
+437
+438    if rows_affected == 0 {
+439        warn!("No user found with id={} to set as active!", user_id);
+440    }
+441
+442    // Verify the user is now active
+443    let verify_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
+444    let verify_count: i32 = db_service
+445        .query_one(verify_query, |row| row.get(0))
+446        .await
+447        .unwrap_or(-1);
+448    debug!("VERIFY: {} active users after set_active", verify_count);
+449    debug!("Database path: {:?}", db_path);
+450
+451    // Force WAL checkpoint to ensure data is persisted to disk
+452    let checkpoint_query = Query::new("PRAGMA wal_checkpoint(PASSIVE)");
+453    match db_service.execute(checkpoint_query).await {
+454        Ok(_) => debug!("WAL checkpoint completed"),
+455        Err(e) => warn!("WAL checkpoint failed: {}", e),
+456    }
+457
+458    Ok(())
+459}
+460
+461/// Get the active user for a server
+462#[tauri::command]
+463#[specta::specta]
+464pub async fn storage_get_active_user(
+465    db: State<'_, DatabaseWrapper>,
+466    server_id: String,
+467) -> Result<Option<UserInfo>, String> {
+468    let db_service = {
+469        let database = db.0.lock().map_err(|e| e.to_string())?;
+470        Arc::new(database.service())
+471    };
+472
+473    let query = Query::with_params(
+474        "SELECT id, server_id, username, is_active FROM users
+475         WHERE server_id = ? AND is_active = 1",
+476        vec![QueryParam::String(server_id)],
+477    );
+478
+479    db_service
+480        .query_optional(query, |row| {
+481            Ok(UserInfo {
+482                id: row.get(0)?,
+483                server_id: row.get(1)?,
+484                username: row.get(2)?,
+485                is_active: true,
+486            })
+487        })
+488        .await
+489        .map_err(|e| e.to_string())
+490}
+491
+492/// Get the active session (user + server + token) for session restoration
+493#[tauri::command]
+494#[specta::specta]
+495pub async fn storage_get_active_session(
+496    db: State<'_, DatabaseWrapper>,
+497    creds: State<'_, CredentialStoreWrapper>,
+498) -> Result<Option<ActiveSession>, String> {
+499    info!("storage_get_active_session called");
+500
+501    let (db_service, db_path) = {
+502        let database = db.0.lock().map_err(|e| e.to_string())?;
+503        let path = database.path().to_path_buf();
+504        (Arc::new(database.service()), path)
+505    };
+506
+507    // Debug: count total users and active users
+508    let total_query = Query::new("SELECT COUNT(*) FROM users");
+509    let total_users: i32 = db_service
+510        .query_one(total_query, |row| row.get(0))
+511        .await
+512        .unwrap_or(-1);
+513
+514    let active_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
+515    let active_users: i32 = db_service
+516        .query_one(active_query, |row| row.get(0))
+517        .await
+518        .unwrap_or(-1);
+519
+520    debug!(
+521        "Database state: {} total users, {} active users",
+522        total_users, active_users
+523    );
+524    debug!("Database path: {:?}", db_path);
+525
+526    // Find active user with their server info, ordered by most recently logged in
+527    let session_query = Query::new(
+528        "SELECT u.id, u.username, u.server_id, s.url, s.name
+529         FROM users u
+530         JOIN servers s ON u.server_id = s.id
+531         WHERE u.is_active = 1
+532         ORDER BY u.last_login_at DESC
+533         LIMIT 1",
+534    );
+535
+536    let result = db_service
+537        .query_optional(session_query, |row| {
+538            Ok((
+539                row.get::<_, String>(0)?,
+540                row.get::<_, String>(1)?,
+541                row.get::<_, String>(2)?,
+542                row.get::<_, String>(3)?,
+543                row.get::<_, String>(4)?,
+544            ))
+545        })
+546        .await
+547        .map_err(|e| e.to_string())?;
+548
+549    match result {
+550        Some((user_id, username, server_id, server_url, server_name)) => {
+551            info!("Found active user: {} ({})", username, user_id);
+552            // Get token from secure storage
+553            let store = creds.0.lock().map_err(|e| e.to_string())?;
+554            match store.get_token(&user_id) {
+555                Ok(access_token) => {
+556                    debug!("Successfully retrieved token from secure storage");
+557                    Ok(Some(ActiveSession {
+558                        user_id,
+559                        username,
+560                        server_id,
+561                        server_url,
+562                        server_name,
+563                        access_token,
+564                    }))
+565                }
+566                Err(e) => {
+567                    // Token not found or error - session is invalid
+568                    warn!("Failed to get token from secure storage: {:?}", e);
+569                    Ok(None)
+570                }
+571            }
+572        }
+573        None => {
+574            info!("No active user found in database");
+575            Ok(None)
+576        }
+577    }
+578}
+579
+580/// Get user's access token from secure storage
+581#[tauri::command]
+582#[specta::specta]
+583pub fn storage_get_access_token(
+584    creds: State<CredentialStoreWrapper>,
+585    user_id: String,
+586) -> Result<Option<String>, String> {
+587    let store = creds.0.lock().map_err(|e| e.to_string())?;
+588    match store.get_token(&user_id) {
+589        Ok(token) => Ok(Some(token)),
+590        Err(crate::credentials::CredentialError::NotFound) => Ok(None),
+591        Err(e) => Err(e.to_string()),
+592    }
+593}
+594
+595/// Delete a user account and their token from secure storage
+596#[tauri::command]
+597#[specta::specta]
+598pub async fn storage_delete_user(
+599    db: State<'_, DatabaseWrapper>,
+600    creds: State<'_, CredentialStoreWrapper>,
+601    user_id: String,
+602) -> Result<(), String> {
+603    info!("storage_delete_user called: user_id={}", user_id);
+604    debug!("STACK TRACE: This is where the user is being deleted!");
+605
+606    // Delete token from secure storage first
+607    {
+608        let store = creds.0.lock().map_err(|e| e.to_string())?;
+609        let _ = store.delete_token(&user_id); // Ignore errors, token might not exist
+610    }
+611
+612    // Delete user from database
+613    let db_service = {
+614        let database = db.0.lock().map_err(|e| e.to_string())?;
+615        Arc::new(database.service())
+616    };
+617
+618    let query = Query::with_params(
+619        "DELETE FROM users WHERE id = ?",
+620        vec![QueryParam::String(user_id)],
+621    );
+622
+623    db_service.execute(query).await.map_err(|e| e.to_string())?;
+624
+625    info!("User deleted successfully");
+626    Ok(())
+627}
+628
+629/// Playback progress info
+630#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+631#[serde(rename_all = "camelCase")]
+632pub struct PlaybackProgress {
+633    pub item_id: String,
+634    /// Resume position in milliseconds. Stored as Jellyfin ticks in the DB;
+635    /// converted here so the frontend never sees ticks.
+636    pub position_ms: i64,
+637    pub is_played: bool,
+638    pub is_favorite: bool,
+639    pub play_count: i32,
+640}
+641
+642/// Update playback progress in local database
+643/// This stores the progress locally for offline access and "continue watching"
+644#[tauri::command]
+645#[specta::specta]
+646pub async fn storage_update_playback_progress(
+647    db: State<'_, DatabaseWrapper>,
+648    user_id: String,
+649    item_id: String,
+650    position_ms: i64,
+651) -> Result<(), String> {
+652    // The frontend speaks milliseconds; ticks are a Jellyfin storage detail that
+653    // stays on this side of the boundary. 10_000 ticks = 1 ms.
+654    let position_ticks = position_ms * 10_000;
+655    let db_service = {
+656        let database = db.0.lock().map_err(|e| e.to_string())?;
+657        Arc::new(database.service())
+658    };
+659
+660    // Note: user_id and item_id are Jellyfin IDs (strings)
+661    // The user_data table uses them directly as strings, not foreign keys to integer IDs
+662    // Insert or update playback position
+663    let query = Query::with_params(
+664        "INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at, pending_sync)
+665         VALUES (?, ?, ?, CURRENT_TIMESTAMP, 1)
+666         ON CONFLICT(user_id, item_id) DO UPDATE SET
+667            playback_position_ticks = excluded.playback_position_ticks,
+668            last_played_at = excluded.last_played_at,
+669            pending_sync = 1",
+670        vec![
+671            QueryParam::String(user_id),
+672            QueryParam::String(item_id.clone()),
+673            QueryParam::Int64(position_ticks),
+674        ],
+675    );
+676
+677    match db_service.execute(query).await {
+678        Ok(_) => Ok(()),
+679        Err(e) if e.contains("constraint") || e.contains("UNIQUE") => {
+680            // Foreign key constraint failed - this can happen if:
+681            // 1. The item isn't synced locally yet (item_id not in items table)
+682            // 2. Database migration 003 hasn't been applied (old database)
+683            //
+684            // In either case, we silently succeed - playback progress will still
+685            // be reported to the server, and local progress will be tracked
+686            // once the item is synced.
+687            debug!(
+688                "Skipping local playback progress for item {} (not cached locally)",
+689                item_id
+690            );
+691            Ok(())
+692        }
+693        Err(e) => Err(format!("Failed to update playback progress: {}", e)),
+694    }
+695}
+696
+697/// Update playback progress with context in local database
+698/// This stores the progress along with playback context (container vs single)
+699#[tauri::command]
+700#[specta::specta]
+701pub async fn storage_update_playback_context(
+702    db: State<'_, DatabaseWrapper>,
+703    user_id: String,
+704    item_id: String,
+705    position_ms: i64,
+706    context_type: Option<String>,
+707    context_id: Option<String>,
+708) -> Result<(), String> {
+709    use crate::storage::db_service::{Query, QueryParam};
+710
+711    // Milliseconds in, Jellyfin ticks stored. 10_000 ticks = 1 ms.
+712    let position_ticks = position_ms * 10_000;
+713    let db_service = {
+714        let database = db.0.lock().map_err(|e| e.to_string())?;
+715        Arc::new(database.service())
+716    };
+717
+718    let query = Query::with_params(
+719        "INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at,
+720                                playback_context_type, playback_context_id, pending_sync)
+721         VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, ?, 1)
+722         ON CONFLICT(user_id, item_id) DO UPDATE SET
+723            playback_position_ticks = excluded.playback_position_ticks,
+724            last_played_at = excluded.last_played_at,
+725            playback_context_type = excluded.playback_context_type,
+726            playback_context_id = excluded.playback_context_id,
+727            pending_sync = 1",
+728        vec![
+729            QueryParam::String(user_id.clone()),
+730            QueryParam::String(item_id.clone()),
+731            QueryParam::Int64(position_ticks),
+732            context_type
+733                .map(QueryParam::String)
+734                .unwrap_or(QueryParam::Null),
+735            context_id
+736                .map(QueryParam::String)
+737                .unwrap_or(QueryParam::Null),
+738        ],
+739    );
+740
+741    match db_service.execute(query).await {
+742        Ok(_) => Ok(()),
+743        Err(e) if e.contains("constraint") || e.contains("UNIQUE") => {
+744            debug!(
+745                "Skipping local playback context for item {} (not cached locally)",
+746                item_id
+747            );
+748            Ok(())
+749        }
+750        Err(e) => Err(format!("Failed to update playback context: {}", e)),
+751    }
+752}
+753
+754/// Mark item as played in local database
+755#[tauri::command]
+756#[specta::specta]
+757pub async fn storage_mark_played(
+758    db: State<'_, DatabaseWrapper>,
+759    smart_cache: State<'_, SmartCacheWrapper>,
+760    user_id: String,
+761    item_id: String,
+762) -> Result<(), String> {
+763    let db_service = {
+764        let database = db.0.lock().map_err(|e| e.to_string())?;
+765        Arc::new(database.service())
+766    };
+767
+768    // Get album_id for this item (if it's an audio track)
+769    let album_query = Query::with_params(
+770        "SELECT album_id, item_type FROM items WHERE id = ?",
+771        vec![QueryParam::String(item_id.clone())],
+772    );
+773
+774    let album_info: Option<(Option<String>, String)> = db_service
+775        .query_optional(album_query, |row| Ok((row.get(0)?, row.get(1)?)))
+776        .await
+777        .unwrap_or(None);
+778
+779    // Track play in SmartCache (for audio tracks with albums)
+780    if let Some((Some(album_id), item_type)) = album_info.clone() {
+781        if item_type == "Audio" {
+782            // Clone cache to avoid holding lock across async operations
+783            let should_cache = {
+784                let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
+785                cache.track_play(&item_id, Some(&album_id));
+786                cache.should_cache_album(&album_id)
+787            };
+788
+789            // Check if we should cache this album
+790            if let Some(true) = should_cache {
+791                info!("Album affinity threshold reached for album {}!", album_id);
+792
+793                // Auto-queue remaining album tracks for download
+794                // Get tracks from this album that aren't already downloaded
+795                let tracks_query = Query::with_params(
+796                    "SELECT i.id, i.name, i.artists, i.album_name
+797                     FROM items i
+798                     LEFT JOIN downloads d ON d.item_id = i.id AND d.user_id = ?
+799                     WHERE i.album_id = ? AND i.item_type = 'Audio'
+800                       AND (d.id IS NULL OR d.status NOT IN ('completed', 'downloading', 'pending'))
+801                     ORDER BY i.index_number",
+802                    vec![
+803                        QueryParam::String(user_id.clone()),
+804                        QueryParam::String(album_id.clone()),
+805                    ],
+806                );
+807
+808                let tracks: Vec<(String, String, Option<String>, Option<String>)> = db_service
+809                    .query_many(tracks_query, |row| {
+810                        Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
+811                    })
+812                    .await
+813                    .unwrap_or_else(|e| {
+814                        warn!("Failed to query album tracks: {}", e);
+815                        Vec::new()
+816                    });
+817
+818                if !tracks.is_empty() {
+819                    info!(
+820                        "Auto-queueing {} tracks from album for download",
+821                        tracks.len()
+822                    );
+823
+824                    // Queue each track with high priority (50) and mark as auto-downloaded
+825                    for (track_id, track_name, artist_name, album_name) in tracks {
+826                        // Generate a sanitized file path (simplified version)
+827                        let sanitized_name = track_name
+828                            .chars()
+829                            .map(|c| {
+830                                if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
+831                                    c
+832                                } else {
+833                                    '_'
+834                                }
+835                            })
+836                            .collect::<String>();
+837                        let file_path = format!("downloads/{}/{}.mp3", album_id, sanitized_name);
+838
+839                        let insert_query = Query::with_params(
+840                            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, download_source)
+841                             VALUES (?, ?, ?, 'pending', 50, CURRENT_TIMESTAMP, ?, ?, ?, 'auto')
+842                             ON CONFLICT(item_id, user_id) DO UPDATE SET
+843                               priority = 50,
+844                               status = 'pending',
+845                               download_source = 'auto'",
+846                            vec![
+847                                QueryParam::String(track_id.clone()),
+848                                QueryParam::String(user_id.clone()),
+849                                QueryParam::String(file_path),
+850                                QueryParam::String(track_name),
+851                                artist_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
+852                                album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
+853                            ],
+854                        );
+855
+856                        if let Err(e) = db_service.execute(insert_query).await {
+857                            warn!("Failed to queue track {}: {}", track_id, e);
+858                        }
+859                    }
+860
+861                    info!("Album tracks queued for automatic download");
+862                } else {
+863                    info!("All album tracks already downloaded or queued");
+864                }
+865            }
+866        }
+867    }
+868
+869    let query = Query::with_params(
+870        "INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
+871         VALUES (?, ?, 1, 1, CURRENT_TIMESTAMP, 1)
+872         ON CONFLICT(user_id, item_id) DO UPDATE SET
+873            is_played = 1,
+874            play_count = play_count + 1,
+875            last_played_at = CURRENT_TIMESTAMP,
+876            pending_sync = 1",
+877        vec![QueryParam::String(user_id), QueryParam::String(item_id.clone())],
+878    );
+879
+880    match db_service.execute(query).await {
+881        Ok(_) => Ok(()),
+882        Err(e) if e.contains("constraint") => {
+883            // Foreign key constraint failed - item not cached locally
+884            debug!(
+885                "Skipping local mark_played for item {} (not cached locally)",
+886                item_id
+887            );
+888            Ok(())
+889        }
+890        Err(e) => Err(e.to_string()),
+891    }
+892}
+893
+894/// Set the watched flag locally for an item **and everything inside it**.
+895///
+896/// This backs the watched toggle, and is deliberately separate from
+897/// [`storage_mark_played`] — which reports a single track/episode finishing and
+898/// increments `play_count` — because the toggle has two directions and applies
+899/// to containers.
+900///
+901/// The recursion is what makes the toggle honest offline. Jellyfin applies
+902/// `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
+903/// online the server fixes up the children on the next read; with no server to
+904/// ask, marking a season watched would otherwise tick the season and leave every
+905/// episode inside it unwatched. Targets are drawn from `items` by the same link
+906/// columns the rest of the offline layer uses, so an id that is not cached
+907/// selects nothing and the statement is a no-op rather than a foreign-key error.
+908///
+909/// Un-marking clears the resume position too, matching the server, so an item
+910/// un-marked offline does not come back offering to resume from a position it is
+911/// no longer meant to have.
+912///
+913/// `pending_sync = 1` hands the rows to the sync drain.
+914///
+915/// TRACES: UR-073 | DR-158
+916#[tauri::command]
+917#[specta::specta]
+918pub async fn storage_set_watched(
+919    db: State<'_, DatabaseWrapper>,
+920    user_id: String,
+921    item_id: String,
+922    watched: bool,
+923) -> Result<(), String> {
+924    let db_service = {
+925        let database = db.0.lock().map_err(|e| e.to_string())?;
+926        Arc::new(database.service())
+927    };
+928
+929    // The item itself plus its descendants: a season's episodes reach it by
+930    // season_id, a series' by series_id, its seasons by parent_id, an album's
+931    // tracks by album_id.
+932    let targets = "SELECT id FROM items
+933                   WHERE id = ? OR parent_id = ? OR album_id = ?
+934                      OR season_id = ? OR series_id = ?";
+935
+936    let sql = if watched {
+937        format!(
+938            "INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
+939             SELECT ?, id, 1, 1, CURRENT_TIMESTAMP, 1 FROM ({targets})
+940             ON CONFLICT(user_id, item_id) DO UPDATE SET
+941                is_played = 1,
+942                play_count = MAX(user_data.play_count, 1),
+943                last_played_at = CURRENT_TIMESTAMP,
+944                pending_sync = 1"
+945        )
+946    } else {
+947        format!(
+948            "INSERT INTO user_data (user_id, item_id, is_played, play_count, playback_position_ticks, pending_sync)
+949             SELECT ?, id, 0, 0, 0, 1 FROM ({targets})
+950             ON CONFLICT(user_id, item_id) DO UPDATE SET
+951                is_played = 0,
+952                play_count = 0,
+953                playback_position_ticks = 0,
+954                pending_sync = 1"
+955        )
+956    };
+957
+958    let query = Query::with_params(
+959        sql,
+960        vec![
+961            QueryParam::String(user_id),
+962            QueryParam::String(item_id.clone()),
+963            QueryParam::String(item_id.clone()),
+964            QueryParam::String(item_id.clone()),
+965            QueryParam::String(item_id.clone()),
+966            QueryParam::String(item_id.clone()),
+967        ],
+968    );
+969
+970    db_service.execute(query).await.map_err(|e| e.to_string())?;
+971    Ok(())
+972}
+973
+974/// Get playback progress for an item
+975#[tauri::command]
+976#[specta::specta]
+977pub async fn storage_get_playback_progress(
+978    db: State<'_, DatabaseWrapper>,
+979    user_id: String,
+980    item_id: String,
+981) -> Result<Option<PlaybackProgress>, String> {
+982    let db_service = {
+983        let database = db.0.lock().map_err(|e| e.to_string())?;
+984        Arc::new(database.service())
+985    };
+986
+987    let query = Query::with_params(
+988        "SELECT item_id, playback_position_ticks, is_played, is_favorite, play_count
+989         FROM user_data WHERE user_id = ? AND item_id = ?",
+990        vec![QueryParam::String(user_id), QueryParam::String(item_id)],
+991    );
+992
+993    db_service
+994        .query_optional(query, |row| {
+995            let position_ticks: i64 = row.get(1)?;
+996            Ok(PlaybackProgress {
+997                item_id: row.get(0)?,
+998                position_ms: position_ticks / 10_000,
+999                is_played: row.get::<_, i32>(2)? != 0,
+1000                is_favorite: row.get::<_, i32>(3)? != 0,
+1001                play_count: row.get(4)?,
+1002            })
+1003        })
+1004        .await
+1005        .map_err(|e| e.to_string())
+1006}
+1007
+1008/// Mark pending sync as completed for an item
+1009#[tauri::command]
+1010#[specta::specta]
+1011pub async fn storage_mark_synced(
+1012    db: State<'_, DatabaseWrapper>,
+1013    user_id: String,
+1014    item_id: String,
+1015) -> Result<(), 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 query = Query::with_params(
+1022        "UPDATE user_data SET pending_sync = 0, synced_at = CURRENT_TIMESTAMP
+1023         WHERE user_id = ? AND item_id = ?",
+1024        vec![QueryParam::String(user_id), QueryParam::String(item_id)],
+1025    );
+1026
+1027    db_service.execute(query).await.map_err(|e| e.to_string())?;
+1028
+1029    Ok(())
+1030}
+1031
+1032/// Toggle favorite status for an item in local database
+1033/// This updates the is_favorite field and marks it for sync to Jellyfin
+1034#[tauri::command]
+1035#[specta::specta]
+1036pub async fn storage_toggle_favorite(
+1037    db: State<'_, DatabaseWrapper>,
+1038    user_id: String,
+1039    item_id: String,
+1040    is_favorite: bool,
+1041) -> Result<bool, String> {
+1042    let db_service = {
+1043        let database = db.0.lock().map_err(|e| e.to_string())?;
+1044        Arc::new(database.service())
+1045    };
+1046
+1047    // Insert or update favorite status
+1048    let query = Query::with_params(
+1049        "INSERT INTO user_data (user_id, item_id, is_favorite, pending_sync)
+1050         VALUES (?, ?, ?, 1)
+1051         ON CONFLICT(user_id, item_id) DO UPDATE SET
+1052            is_favorite = excluded.is_favorite,
+1053            pending_sync = 1",
+1054        vec![
+1055            QueryParam::String(user_id),
+1056            QueryParam::String(item_id.clone()),
+1057            QueryParam::Int(is_favorite as i32),
+1058        ],
+1059    );
+1060
+1061    match db_service.execute(query).await {
+1062        Ok(_) => Ok(is_favorite),
+1063        Err(e) if e.contains("constraint") => {
+1064            // Foreign key constraint failed - item not cached locally
+1065            // Still return the requested state, server update will work separately
+1066            debug!(
+1067                "Skipping local favorite toggle for item {} (not cached locally)",
+1068                item_id
+1069            );
+1070            Ok(is_favorite)
+1071        }
+1072        Err(e) => Err(format!("Failed to toggle favorite: {}", e)),
+1073    }
+1074}
+1075
+1076// =============================================================================
+1077// Offline Data Queries
+1078// =============================================================================
+1079
+1080/// Cached library info returned to frontend
+1081#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+1082#[serde(rename_all = "camelCase")]
+1083pub struct CachedLibrary {
+1084    pub id: String,
+1085    pub server_id: String,
+1086    pub name: String,
+1087    pub collection_type: Option<String>,
+1088    pub image_tag: Option<String>,
+1089}
+1090
+1091/// Cached media item returned to frontend
+1092#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+1093#[serde(rename_all = "camelCase")]
+1094pub struct CachedItem {
+1095    pub id: String,
+1096    pub name: String,
+1097    pub item_type: String,
+1098    pub parent_id: Option<String>,
+1099    pub library_id: Option<String>,
+1100    pub overview: Option<String>,
+1101    pub genres: Option<String>,
+1102    pub runtime_ticks: Option<i64>,
+1103    pub production_year: Option<i32>,
+1104    pub community_rating: Option<f64>,
+1105    pub official_rating: Option<String>,
+1106    pub primary_image_tag: Option<String>,
+1107    // Music-specific
+1108    pub album_id: Option<String>,
+1109    pub album_name: Option<String>,
+1110    pub album_artist: Option<String>,
+1111    pub artists: Option<String>,
+1112    pub index_number: Option<i32>,
+1113    // TV-specific
+1114    pub series_id: Option<String>,
+1115    pub series_name: Option<String>,
+1116    pub season_id: Option<String>,
+1117    pub season_name: Option<String>,
+1118    pub parent_index_number: Option<i32>,
+1119}
+1120
+1121/// Get cached libraries for a server
+1122#[tauri::command]
+1123#[specta::specta]
+1124pub async fn storage_get_libraries(
+1125    db: State<'_, DatabaseWrapper>,
+1126    server_id: String,
+1127) -> Result<Vec<CachedLibrary>, String> {
+1128    let db_service = {
+1129        let database = db.0.lock().map_err(|e| e.to_string())?;
+1130        Arc::new(database.service())
+1131    };
+1132
+1133    let query = Query::with_params(
+1134        "SELECT id, server_id, name, collection_type, image_tag
+1135         FROM libraries
+1136         WHERE server_id = ?
+1137         ORDER BY sort_order ASC, name ASC",
+1138        vec![QueryParam::String(server_id)],
+1139    );
+1140
+1141    let libraries = db_service
+1142        .query_many(query, |row| {
+1143            Ok(CachedLibrary {
+1144                id: row.get(0)?,
+1145                server_id: row.get(1)?,
+1146                name: row.get(2)?,
+1147                collection_type: row.get(3)?,
+1148                image_tag: row.get(4)?,
+1149            })
+1150        })
+1151        .await
+1152        .map_err(|e| e.to_string())?;
+1153
+1154    Ok(libraries)
+1155}
+1156
+1157fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
+1158    Ok(CachedItem {
+1159        id: row.get(0)?,
+1160        name: row.get(1)?,
+1161        item_type: row.get(2)?,
+1162        parent_id: row.get(3)?,
+1163        library_id: row.get(4)?,
+1164        overview: row.get(5)?,
+1165        genres: row.get(6)?,
+1166        runtime_ticks: row.get(7)?,
+1167        production_year: row.get(8)?,
+1168        community_rating: row.get(9)?,
+1169        official_rating: row.get(10)?,
+1170        primary_image_tag: row.get(11)?,
+1171        album_id: row.get(12)?,
+1172        album_name: row.get(13)?,
+1173        album_artist: row.get(14)?,
+1174        artists: row.get(15)?,
+1175        index_number: row.get(16)?,
+1176        series_id: row.get(17)?,
+1177        series_name: row.get(18)?,
+1178        season_id: row.get(19)?,
+1179        season_name: row.get(20)?,
+1180        parent_index_number: row.get(21)?,
+1181    })
+1182}
+1183
+1184/// Get cached items with optional filtering
+1185#[tauri::command]
+1186#[specta::specta]
+1187pub async fn storage_get_items(
+1188    db: State<'_, DatabaseWrapper>,
+1189    server_id: String,
+1190    parent_id: Option<String>,
+1191    library_id: Option<String>,
+1192    item_type: Option<String>,
+1193    limit: Option<i32>,
+1194    offset: Option<i32>,
+1195) -> Result<Vec<CachedItem>, String> {
+1196    let db_service = {
+1197        let database = db.0.lock().map_err(|e| e.to_string())?;
+1198        Arc::new(database.service())
+1199    };
+1200
+1201    // Build base query
+1202    let base_select = "SELECT id, name, item_type, parent_id, library_id, overview, genres,
+1203                runtime_ticks, production_year, community_rating, official_rating,
+1204                primary_image_tag, album_id, album_name, album_artist, artists,
+1205                index_number, series_id, series_name, season_id, season_name,
+1206                parent_index_number
+1207         FROM items
+1208         WHERE server_id = ?";
+1209
+1210    let mut conditions = Vec::new();
+1211    let mut params = vec![QueryParam::String(server_id)];
+1212
+1213    if let Some(pid) = parent_id {
+1214        conditions.push("parent_id = ?");
+1215        params.push(QueryParam::String(pid));
+1216    }
+1217
+1218    if let Some(lid) = library_id {
+1219        conditions.push("library_id = ?");
+1220        params.push(QueryParam::String(lid));
+1221    }
+1222
+1223    if let Some(itype) = item_type {
+1224        conditions.push("item_type = ?");
+1225        params.push(QueryParam::String(itype));
+1226    }
+1227
+1228    let mut sql = base_select.to_string();
+1229    for cond in &conditions {
+1230        sql.push_str(&format!(" AND {}", cond));
+1231    }
+1232    sql.push_str(" ORDER BY sort_name ASC, name ASC");
+1233
+1234    if let Some(lim) = limit {
+1235        sql.push_str(&format!(" LIMIT {}", lim));
+1236    }
+1237    if let Some(off) = offset {
+1238        sql.push_str(&format!(" OFFSET {}", off));
+1239    }
+1240
+1241    let query = Query::with_params(sql, params);
+1242
+1243    let items = db_service
+1244        .query_many(query, row_to_cached_item)
+1245        .await
+1246        .map_err(|e| e.to_string())?;
+1247
+1248    Ok(items)
+1249}
+1250
+1251/// Get a single cached item by ID
+1252#[tauri::command]
+1253#[specta::specta]
+1254pub async fn storage_get_item(
+1255    db: State<'_, DatabaseWrapper>,
+1256    item_id: String,
+1257) -> Result<Option<CachedItem>, String> {
+1258    let db_service = {
+1259        let database = db.0.lock().map_err(|e| e.to_string())?;
+1260        Arc::new(database.service())
+1261    };
+1262
+1263    let query = Query::with_params(
+1264        "SELECT id, name, item_type, parent_id, library_id, overview, genres,
+1265                runtime_ticks, production_year, community_rating, official_rating,
+1266                primary_image_tag, album_id, album_name, album_artist, artists,
+1267                index_number, series_id, series_name, season_id, season_name,
+1268                parent_index_number
+1269         FROM items WHERE id = ?",
+1270        vec![QueryParam::String(item_id)],
+1271    );
+1272
+1273    db_service
+1274        .query_optional(query, row_to_cached_item)
+1275        .await
+1276        .map_err(|e| e.to_string())
+1277}
+1278
+1279/// Search cached items using FTS
+1280#[tauri::command]
+1281#[specta::specta]
+1282pub async fn storage_search_items(
+1283    db: State<'_, DatabaseWrapper>,
+1284    server_id: String,
+1285    query: String,
+1286    limit: Option<i32>,
+1287) -> Result<Vec<CachedItem>, String> {
+1288    let db_service = {
+1289        let database = db.0.lock().map_err(|e| e.to_string())?;
+1290        Arc::new(database.service())
+1291    };
+1292
+1293    // Escape FTS special characters and add prefix matching
+1294    let fts_query = format!("{}*", query.replace('"', "\"\""));
+1295
+1296    let limit_clause = limit.map(|l| format!(" LIMIT {}", l)).unwrap_or_default();
+1297
+1298    let sql = format!(
+1299        "SELECT i.id, i.name, i.item_type, i.parent_id, i.library_id, i.overview, i.genres,
+1300                i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
+1301                i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
+1302                i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
+1303                i.parent_index_number
+1304         FROM items i
+1305         JOIN items_fts fts ON fts.rowid = i.rowid
+1306         WHERE i.server_id = ? AND items_fts MATCH ?
+1307         ORDER BY rank{}",
+1308        limit_clause
+1309    );
+1310
+1311    let query_obj = Query::with_params(
+1312        sql,
+1313        vec![QueryParam::String(server_id), QueryParam::String(fts_query)],
+1314    );
+1315
+1316    let items = db_service
+1317        .query_many(query_obj, row_to_cached_item)
+1318        .await
+1319        .map_err(|e| e.to_string())?;
+1320
+1321    Ok(items)
+1322}
+1323
+1324/// Save a library to the cache
+1325#[tauri::command]
+1326#[specta::specta]
+1327pub async fn storage_save_library(
+1328    db: State<'_, DatabaseWrapper>,
+1329    id: String,
+1330    server_id: String,
+1331    name: String,
+1332    collection_type: Option<String>,
+1333    image_tag: Option<String>,
+1334    sort_order: Option<i32>,
+1335) -> Result<(), String> {
+1336    let db_service = {
+1337        let database = db.0.lock().map_err(|e| e.to_string())?;
+1338        Arc::new(database.service())
+1339    };
+1340
+1341    let query = Query::with_params(
+1342        "INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at)
+1343         VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
+1344        vec![
+1345            QueryParam::String(id),
+1346            QueryParam::String(server_id),
+1347            QueryParam::String(name),
+1348            collection_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
+1349            image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null),
+1350            QueryParam::Int(sort_order.unwrap_or(0)),
+1351        ],
+1352    );
+1353
+1354    db_service.execute(query).await.map_err(|e| e.to_string())?;
+1355    Ok(())
+1356}
+1357
+1358/// Save an item to the cache
+1359#[tauri::command]
+1360#[specta::specta]
+1361pub async fn storage_save_item(
+1362    db: State<'_, DatabaseWrapper>,
+1363    item: CachedItem,
+1364    server_id: String,
+1365) -> Result<(), String> {
+1366    let db_service = {
+1367        let database = db.0.lock().map_err(|e| e.to_string())?;
+1368        Arc::new(database.service())
+1369    };
+1370
+1371    // Generate sort_name from name (remove leading "The ", "A ", etc.)
+1372    let sort_name = item
+1373        .name
+1374        .strip_prefix("The ")
+1375        .or_else(|| item.name.strip_prefix("A "))
+1376        .or_else(|| item.name.strip_prefix("An "))
+1377        .unwrap_or(&item.name)
+1378        .to_string();
+1379
+1380    let query = Query::with_params(
+1381        "INSERT OR REPLACE INTO items (
+1382            id, server_id, library_id, parent_id, name, sort_name, item_type,
+1383            overview, genres, runtime_ticks, production_year, community_rating,
+1384            official_rating, primary_image_tag, album_id, album_name, album_artist,
+1385            artists, index_number, series_id, series_name, season_id, season_name,
+1386            parent_index_number, synced_at
+1387         ) VALUES (
+1388            ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
+1389            ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP
+1390         )",
+1391        vec![
+1392            QueryParam::String(item.id),
+1393            QueryParam::String(server_id),
+1394            item.library_id
+1395                .map(QueryParam::String)
+1396                .unwrap_or(QueryParam::Null),
+1397            item.parent_id
+1398                .map(QueryParam::String)
+1399                .unwrap_or(QueryParam::Null),
+1400            QueryParam::String(item.name),
+1401            QueryParam::String(sort_name),
+1402            QueryParam::String(item.item_type),
+1403            item.overview
+1404                .map(QueryParam::String)
+1405                .unwrap_or(QueryParam::Null),
+1406            item.genres
+1407                .map(QueryParam::String)
+1408                .unwrap_or(QueryParam::Null),
+1409            item.runtime_ticks
+1410                .map(QueryParam::Int64)
+1411                .unwrap_or(QueryParam::Null),
+1412            item.production_year
+1413                .map(QueryParam::Int)
+1414                .unwrap_or(QueryParam::Null),
+1415            item.community_rating
+1416                .map(QueryParam::Float)
+1417                .unwrap_or(QueryParam::Null),
+1418            item.official_rating
+1419                .map(QueryParam::String)
+1420                .unwrap_or(QueryParam::Null),
+1421            item.primary_image_tag
+1422                .map(QueryParam::String)
+1423                .unwrap_or(QueryParam::Null),
+1424            item.album_id
+1425                .map(QueryParam::String)
+1426                .unwrap_or(QueryParam::Null),
+1427            item.album_name
+1428                .map(QueryParam::String)
+1429                .unwrap_or(QueryParam::Null),
+1430            item.album_artist
+1431                .map(QueryParam::String)
+1432                .unwrap_or(QueryParam::Null),
+1433            item.artists
+1434                .map(QueryParam::String)
+1435                .unwrap_or(QueryParam::Null),
+1436            item.index_number
+1437                .map(QueryParam::Int)
+1438                .unwrap_or(QueryParam::Null),
+1439            item.series_id
+1440                .map(QueryParam::String)
+1441                .unwrap_or(QueryParam::Null),
+1442            item.series_name
+1443                .map(QueryParam::String)
+1444                .unwrap_or(QueryParam::Null),
+1445            item.season_id
+1446                .map(QueryParam::String)
+1447                .unwrap_or(QueryParam::Null),
+1448            item.season_name
+1449                .map(QueryParam::String)
+1450                .unwrap_or(QueryParam::Null),
+1451            item.parent_index_number
+1452                .map(QueryParam::Int)
+1453                .unwrap_or(QueryParam::Null),
+1454        ],
+1455    );
+1456
+1457    db_service.execute(query).await.map_err(|e| e.to_string())?;
+1458    Ok(())
+1459}
+1460
+1461/// Get count of pending sync operations for a user
+1462#[tauri::command]
+1463#[specta::specta]
+1464pub async fn storage_get_pending_sync_count(
+1465    db: State<'_, DatabaseWrapper>,
+1466    user_id: String,
+1467) -> Result<i32, String> {
+1468    let db_service = {
+1469        let database = db.0.lock().map_err(|e| e.to_string())?;
+1470        Arc::new(database.service())
+1471    };
+1472
+1473    let query = Query::with_params(
+1474        "SELECT COUNT(*) FROM user_data WHERE user_id = ? AND pending_sync = 1",
+1475        vec![QueryParam::String(user_id)],
+1476    );
+1477
+1478    let count: i32 = db_service
+1479        .query_one(query, |row| row.get(0))
+1480        .await
+1481        .map_err(|e| e.to_string())?;
+1482
+1483    Ok(count)
+1484}
+1485
+1486#[cfg(test)]
+1487mod tests {
+1488    use super::*;
+1489
+1490    #[test]
+1491    fn test_server_info_serialization() {
+1492        let server = ServerInfo {
+1493            id: "server-123".to_string(),
+1494            name: "My Server".to_string(),
+1495            url: "https://jellyfin.example.com".to_string(),
+1496            version: Some("10.8.0".to_string()),
+1497        };
+1498
+1499        let json = serde_json::to_string(&server);
+1500        assert!(json.is_ok());
+1501        let serialized = json.unwrap();
+1502        assert!(serialized.contains("server-123"));
+1503        assert!(serialized.contains("My Server"));
+1504    }
+1505
+1506    #[test]
+1507    fn test_server_info_without_version() {
+1508        let server = ServerInfo {
+1509            id: "server-456".to_string(),
+1510            name: "Test Server".to_string(),
+1511            url: "https://test.local".to_string(),
+1512            version: None,
+1513        };
+1514
+1515        let json = serde_json::to_string(&server).unwrap();
+1516        assert!(json.contains("null") || json.contains("\"version\":null"));
+1517    }
+1518
+1519    #[test]
+1520    fn test_server_info_roundtrip() {
+1521        let original = ServerInfo {
+1522            id: "srv-999".to_string(),
+1523            name: "Production".to_string(),
+1524            url: "https://prod.jellyfin.example.com:8096".to_string(),
+1525            version: Some("10.9.0".to_string()),
+1526        };
+1527
+1528        let json = serde_json::to_string(&original).unwrap();
+1529        let deserialized: ServerInfo = serde_json::from_str(&json).unwrap();
+1530
+1531        assert_eq!(original.id, deserialized.id);
+1532        assert_eq!(original.name, deserialized.name);
+1533        assert_eq!(original.url, deserialized.url);
+1534        assert_eq!(original.version, deserialized.version);
+1535    }
+1536
+1537    #[test]
+1538    fn test_user_info_serialization() {
+1539        let user = UserInfo {
+1540            id: "user-123".to_string(),
+1541            server_id: "server-456".to_string(),
+1542            username: "john_doe".to_string(),
+1543            is_active: true,
+1544        };
+1545
+1546        let json = serde_json::to_string(&user);
+1547        assert!(json.is_ok());
+1548        let serialized = json.unwrap();
+1549        assert!(serialized.contains("user-123"));
+1550        assert!(serialized.contains("john_doe"));
+1551    }
+1552
+1553    #[test]
+1554    fn test_user_info_inactive() {
+1555        let user = UserInfo {
+1556            id: "user-inactive".to_string(),
+1557            server_id: "server-789".to_string(),
+1558            username: "jane_doe".to_string(),
+1559            is_active: false,
+1560        };
+1561
+1562        let json = serde_json::to_string(&user).unwrap();
+1563        assert!(json.contains("false"));
+1564
+1565        let deserialized: UserInfo = serde_json::from_str(&json).unwrap();
+1566        assert!(!deserialized.is_active);
+1567    }
+1568
+1569    #[test]
+1570    fn test_active_session_serialization() {
+1571        let session = ActiveSession {
+1572            user_id: "user-001".to_string(),
+1573            username: "alice".to_string(),
+1574            server_id: "server-001".to_string(),
+1575            server_url: "https://jellyfin.example.com".to_string(),
+1576            server_name: "Home Jellyfin".to_string(),
+1577            access_token: "very-long-token-string-abc123".to_string(),
+1578        };
+1579
+1580        let json = serde_json::to_string(&session);
+1581        assert!(json.is_ok());
+1582        let serialized = json.unwrap();
+1583        assert!(serialized.contains("alice"));
+1584        assert!(serialized.contains("Home Jellyfin"));
+1585    }
+1586
+1587    #[test]
+1588    fn test_active_session_roundtrip() {
+1589        let original = ActiveSession {
+1590            user_id: "u999".to_string(),
+1591            username: "testuser".to_string(),
+1592            server_id: "s999".to_string(),
+1593            server_url: "https://test.example.com:8096".to_string(),
+1594            server_name: "Test Server".to_string(),
+1595            access_token: "token-xyz".to_string(),
+1596        };
+1597
+1598        let json = serde_json::to_string(&original).unwrap();
+1599        let deserialized: ActiveSession = serde_json::from_str(&json).unwrap();
+1600
+1601        assert_eq!(original.user_id, deserialized.user_id);
+1602        assert_eq!(original.username, deserialized.username);
+1603        assert_eq!(original.access_token, deserialized.access_token);
+1604    }
+1605
+1606    #[test]
+1607    fn test_security_status_with_keyring() {
+1608        let status = SecurityStatus {
+1609            using_keyring: true,
+1610            storage_type: "system_keyring".to_string(),
+1611        };
+1612
+1613        let json = serde_json::to_string(&status).unwrap();
+1614        assert!(json.contains("true"));
+1615        assert!(json.contains("system_keyring"));
+1616    }
+1617
+1618    #[test]
+1619    fn test_security_status_with_encrypted_file() {
+1620        let status = SecurityStatus {
+1621            using_keyring: false,
+1622            storage_type: "encrypted_file".to_string(),
+1623        };
+1624
+1625        let json = serde_json::to_string(&status).unwrap();
+1626        assert!(json.contains("false"));
+1627        assert!(json.contains("encrypted_file"));
+1628    }
+1629
+1630    #[test]
+1631    fn test_playback_progress_serialization() {
+1632        let progress = PlaybackProgress {
+1633            item_id: "item-123".to_string(),
+1634            position_ms: 150_000_000,
+1635            is_played: true,
+1636            is_favorite: false,
+1637            play_count: 3,
+1638        };
+1639
+1640        let json = serde_json::to_string(&progress);
+1641        assert!(json.is_ok());
+1642        let serialized = json.unwrap();
+1643        assert!(serialized.contains("item-123"));
+1644        assert!(serialized.contains("150000000"));
+1645    }
+1646
+1647    #[test]
+1648    fn test_playback_progress_played_status() {
+1649        let progress = PlaybackProgress {
+1650            item_id: "item-456".to_string(),
+1651            position_ms: 0,
+1652            is_played: true,
+1653            is_favorite: true,
+1654            play_count: 1,
+1655        };
+1656
+1657        let json = serde_json::to_string(&progress).unwrap();
+1658        let deserialized: PlaybackProgress = serde_json::from_str(&json).unwrap();
+1659
+1660        assert!(deserialized.is_played);
+1661        assert!(deserialized.is_favorite);
+1662        assert_eq!(deserialized.play_count, 1);
+1663    }
+1664
+1665    #[test]
+1666    fn test_playback_progress_not_played() {
+1667        let progress = PlaybackProgress {
+1668            item_id: "item-789".to_string(),
+1669            position_ms: 30_000_000,
+1670            is_played: false,
+1671            is_favorite: false,
+1672            play_count: 0,
+1673        };
+1674
+1675        let json = serde_json::to_string(&progress).unwrap();
+1676        let deserialized: PlaybackProgress = serde_json::from_str(&json).unwrap();
+1677
+1678        assert!(!deserialized.is_played);
+1679        assert_eq!(deserialized.play_count, 0);
+1680    }
+1681
+1682    #[test]
+1683    fn test_database_wrapper_structure() {
+1684        // Verify DatabaseWrapper can be created and holds Mutex<Database>
+1685        assert!(std::mem::size_of::<DatabaseWrapper>() > 0);
+1686    }
+1687
+1688    #[test]
+1689    fn test_credential_store_wrapper_structure() {
+1690        // Verify CredentialStoreWrapper can be created
+1691        assert!(std::mem::size_of::<CredentialStoreWrapper>() > 0);
+1692    }
+1693
+1694    #[test]
+1695    fn test_thumbnail_cache_wrapper_structure() {
+1696        // Verify ThumbnailCacheWrapper holds Arc<ThumbnailCache>
+1697        assert!(std::mem::size_of::<ThumbnailCacheWrapper>() > 0);
+1698    }
+1699
+1700    #[test]
+1701    fn test_user_info_camel_case() {
+1702        let user = UserInfo {
+1703            id: "u1".to_string(),
+1704            server_id: "s1".to_string(),
+1705            username: "user1".to_string(),
+1706            is_active: true,
+1707        };
+1708
+1709        let json = serde_json::to_string(&user).unwrap();
+1710        // Verify camelCase serialization
+1711        assert!(json.contains("serverId"));
+1712        assert!(json.contains("isActive"));
+1713    }
+1714
+1715    #[test]
+1716    fn test_active_session_camel_case() {
+1717        let session = ActiveSession {
+1718            user_id: "u1".to_string(),
+1719            username: "user1".to_string(),
+1720            server_id: "s1".to_string(),
+1721            server_url: "url1".to_string(),
+1722            server_name: "name1".to_string(),
+1723            access_token: "token1".to_string(),
+1724        };
+1725
+1726        let json = serde_json::to_string(&session).unwrap();
+1727        // Verify camelCase serialization
+1728        assert!(json.contains("userId"));
+1729        assert!(json.contains("serverId"));
+1730        assert!(json.contains("serverUrl"));
+1731        assert!(json.contains("serverName"));
+1732        assert!(json.contains("accessToken"));
+1733    }
+1734
+1735    #[test]
+1736    fn test_playback_progress_camel_case() {
+1737        let progress = PlaybackProgress {
+1738            item_id: "i1".to_string(),
+1739            position_ms: 100,
+1740            is_played: true,
+1741            is_favorite: false,
+1742            play_count: 1,
+1743        };
+1744
+1745        let json = serde_json::to_string(&progress).unwrap();
+1746        // Verify camelCase serialization
+1747        assert!(json.contains("itemId"));
+1748        assert!(json.contains("positionMs"));
+1749        assert!(json.contains("isPlayed"));
+1750        assert!(json.contains("isFavorite"));
+1751        assert!(json.contains("playCount"));
+1752    }
+1753}
+
\ 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..acca7bbc --- /dev/null +++ b/api/src/jellytau_lib/lib.rs.html @@ -0,0 +1,1695 @@ +lib.rs - sourceSkip to main content

jellytau_lib/
lib.rs

1#[cfg(target_os = "android")]
+2mod android_context;
+3mod auth;
+4mod commands;
+5/// The MediaPlayer conformance suite, exposed for the `player-conformance`
+6/// binary. One entry point rather than a public player module tree.
+7#[cfg(feature = "conformance")]
+8pub mod conformance_runner;
+9mod connectivity;
+10mod credentials;
+11mod domain;
+12mod download;
+13mod jellyfin;
+14mod media_server;
+15mod playback_mode;
+16mod playback_reporting;
+17mod player;
+18mod repository;
+19mod session_poller;
+20pub mod settings;
+21mod storage;
+22mod thumbnail;
+23pub mod utils;
+24
+25#[cfg(target_os = "android")]
+26use log::warn;
+27use log::{error, info};
+28use std::sync::{Arc, Mutex};
+29use tauri::{Emitter, Manager};
+30use tauri_specta::Builder;
+31use tokio::sync::Mutex as TokioMutex;
+32
+33use auth::AuthManager;
+34use commands::{
+35    auth_connect_to_server,
+36    auth_get_session,
+37    // Auth commands
+38    auth_initialize,
+39    auth_login,
+40    auth_logout,
+41    auth_reauthenticate,
+42    auth_set_session,
+43    auth_start_verification,
+44    auth_stop_verification,
+45    auth_verify_session,
+46    calc_progress,
+47    cancel_download,
+48    catalog_sync_status,
+49    clear_stale_downloads,
+50    // Connectivity commands
+51    connectivity_check_server,
+52    connectivity_get_status,
+53    connectivity_mark_reachable,
+54    connectivity_mark_unreachable,
+55    connectivity_set_server_url,
+56    connectivity_start_monitoring,
+57    connectivity_stop_monitoring,
+58    convert_percent_to_volume,
+59    convert_ticks_to_seconds,
+60    delete_album_downloads,
+61    delete_all_downloads,
+62    delete_download,
+63    delete_downloads_under,
+64    // Device commands
+65    device_get_id,
+66    device_set_id,
+67    // Diagnostics commands
+68    diagnostics_export,
+69    diagnostics_get_info,
+70    diagnostics_set_level,
+71    download_album,
+72    download_item,
+73    download_item_and_start,
+74    download_season,
+75    download_series,
+76    download_video,
+77    enqueue_download,
+78    enqueue_video_downloads,
+79    // Conversion commands
+80    format_time_seconds,
+81    format_time_seconds_long,
+82    get_album_affinity_status,
+83    get_album_recommendations,
+84    get_download_manager_stats,
+85    get_download_storage_stats,
+86    get_downloads,
+87    get_downloads_allowed,
+88    get_smart_cache_config,
+89    get_smart_cache_stats,
+90    image_get_url,
+91    is_item_pinned,
+92    // Library browsing preferences (hidden folders)
+93    library_get_exclusion_candidates,
+94    library_get_settings,
+95    library_set_settings,
+96    lms_create_sync_group,
+97    lms_dissolve_sync_group,
+98    // LMS multi-room sync group commands
+99    lms_get_sync_groups,
+100    lms_unsync_player,
+101    mark_download_completed,
+102    mark_download_failed,
+103    media_local_selection,
+104    media_local_url,
+105    offline_get_items,
+106    offline_is_available,
+107    offline_search,
+108    pause_download,
+109    pin_item,
+110    playback_mark_played,
+111    // Playback mode commands
+112    playback_mode_get_current,
+113    playback_mode_get_remote_status,
+114    playback_mode_is_transferring,
+115    playback_mode_set,
+116    playback_mode_set_transferring,
+117    playback_mode_transfer_to_local,
+118    playback_mode_transfer_to_remote,
+119    playback_report_progress,
+120    playback_report_start,
+121    playback_report_stopped,
+122    playback_reporter_destroy,
+123    // Playback reporting commands
+124    playback_reporter_init,
+125    // Queue manipulation commands
+126    player_add_to_queue,
+127    player_add_track_by_id,
+128    player_add_tracks_by_ids,
+129    player_background_action,
+130    player_cancel_autoplay_countdown,
+131    player_cancel_sleep_timer,
+132    // Jellyfin reporting commands
+133    player_configure_jellyfin,
+134    player_cycle_repeat,
+135    player_disable_jellyfin,
+136    player_dismiss_session,
+137    player_enter_background_audio,
+138    player_exit_background_audio,
+139    player_get_audio_settings,
+140    player_get_autoplay_settings,
+141    player_get_cache_config,
+142    player_get_capabilities,
+143    player_get_eq_presets,
+144    player_get_queue,
+145    // Session management commands
+146    player_get_session,
+147    player_get_sleep_timer,
+148    player_get_status,
+149    player_get_streaming_qualities,
+150    player_get_video_settings,
+151    // Preload commands
+152    player_local_media_path,
+153    player_move_in_queue,
+154    player_next,
+155    player_on_playback_ended,
+156    player_pause,
+157    player_play,
+158    player_play_album_track,
+159    player_play_item,
+160    player_play_next_episode,
+161    player_play_queue,
+162    player_play_tracks,
+163    player_preload_upcoming,
+164    player_previous,
+165    player_recover_stream,
+166    player_remove_from_queue,
+167    player_report_media_loaded,
+168    player_report_position,
+169    // HTML5 video state-report commands
+170    player_report_state,
+171    player_seek,
+172    player_seek_video,
+173    player_set_audio_settings,
+174    player_set_audio_track,
+175    player_set_autoplay_settings,
+176    player_set_cache_config,
+177    // Sleep timer and autoplay commands
+178    player_set_sleep_timer,
+179    player_set_stream_quality,
+180    player_set_subtitle_track,
+181    player_set_video_settings,
+182    player_set_volume,
+183    player_skip_to,
+184    player_stop,
+185    player_switch_audio_track,
+186    player_toggle,
+187    player_toggle_mute,
+188    player_toggle_shuffle,
+189    playlist_add_items,
+190    // Playlist commands
+191    playlist_create,
+192    playlist_delete,
+193    playlist_get_items,
+194    playlist_move_item,
+195    playlist_remove_items,
+196    playlist_rename,
+197    // Remote session control commands
+198    remote_play_on_session,
+199    remote_send_command,
+200    remote_session_seek,
+201    remote_session_set_volume,
+202    remote_session_toggle_mute,
+203    // Repository commands
+204    repository_clear_watch_history,
+205    repository_create,
+206    repository_destroy,
+207    repository_get_audio_only_stream_url_for_video,
+208    repository_get_audio_stream_url,
+209    repository_get_channels,
+210    repository_get_download_disk_usage,
+211    repository_get_downloaded_items,
+212    repository_get_downloaded_libraries,
+213    repository_get_favorites,
+214    repository_get_genres,
+215    repository_get_image_url,
+216    repository_get_item,
+217    repository_get_items,
+218    repository_get_items_by_person,
+219    repository_get_latest_items,
+220    repository_get_libraries,
+221    repository_get_live_tv_channels,
+222    repository_get_next_up_episodes,
+223    repository_get_person,
+224    repository_get_playback_info,
+225    repository_get_recently_played_audio,
+226    repository_get_rediscover_albums,
+227    repository_get_resume_items,
+228    repository_get_resume_movies,
+229    repository_get_series_current_episode,
+230    repository_get_series_episodes,
+231    repository_get_similar_items,
+232    repository_get_stream_selection,
+233    repository_get_subtitle_url,
+234    repository_get_video_download_url,
+235    repository_get_video_stream_url,
+236    repository_jray_actors_at,
+237    repository_mark_favorite,
+238    repository_open_live_stream,
+239    repository_report_playback_progress,
+240    repository_report_playback_start,
+241    repository_report_playback_stopped,
+242    repository_search,
+243    repository_unmark_favorite,
+244    resume_download,
+245    resume_queued_downloads,
+246    sessions_poll_now,
+247    // Session polling commands
+248    sessions_set_polling_hint,
+249    set_max_concurrent_downloads,
+250    set_network_state,
+251    set_show_server_catalog,
+252    start_download,
+253    // Storage commands
+254    storage_delete_server,
+255    storage_delete_user,
+256    storage_get_access_token,
+257    storage_get_active_session,
+258    storage_get_active_user,
+259    storage_get_item,
+260    storage_get_item_people,
+261    storage_get_items,
+262    // Offline cache commands
+263    storage_get_libraries,
+264    storage_get_path,
+265    storage_get_pending_sync_count,
+266    storage_get_person,
+267    storage_get_playback_progress,
+268    storage_get_security_status,
+269    storage_get_series_audio_preference,
+270    storage_get_servers,
+271    storage_get_size,
+272    storage_get_users,
+273    storage_init,
+274    storage_mark_played,
+275    storage_mark_synced,
+276    storage_save_item,
+277    storage_save_item_people,
+278    storage_save_library,
+279    // People cache commands
+280    storage_save_person,
+281    // Series audio preferences
+282    storage_save_series_audio_preference,
+283    storage_save_server,
+284    storage_save_user,
+285    storage_search_items,
+286    storage_set_active_user,
+287    storage_set_watched,
+288    storage_toggle_favorite,
+289    storage_update_playback_context,
+290    storage_update_playback_progress,
+291    sync_cleanup_completed,
+292    sync_clear_user,
+293    sync_full_catalog,
+294    sync_get_pending,
+295    sync_get_pending_count,
+296    sync_mark_completed,
+297    sync_mark_failed,
+298    sync_mark_processing,
+299    sync_process_pending,
+300    // Sync queue commands
+301    sync_queue_mutation,
+302    thumbnail_clear_cache,
+303    thumbnail_delete_item,
+304    // Thumbnail cache and image commands
+305    thumbnail_get_cached,
+306    thumbnail_get_stats,
+307    thumbnail_save,
+308    thumbnail_set_limit,
+309    unpin_item,
+310    update_smart_cache_config,
+311    AuthManagerWrapper,
+312    ConnectivityMonitorWrapper,
+313    CredentialStoreWrapper,
+314    DatabaseWrapper,
+315    DownloadManagerWrapper,
+316    MediaSessionManagerWrapper,
+317    PlaybackModeManagerWrapper,
+318    PlaybackReporterWrapper,
+319    PlayerStateWrapper,
+320    RepositoryManagerWrapper,
+321    SessionPollerWrapper,
+322    SessionVerifierWrapper,
+323    SmartCacheWrapper,
+324    ThumbnailCacheWrapper,
+325    VideoSettingsWrapper,
+326};
+327use connectivity::ConnectivityMonitor;
+328use credentials::CredentialStore;
+329use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
+330use download::DownloadManager;
+331use jellyfin::{HttpClient, HttpConfig};
+332#[cfg(target_os = "android")]
+333use playback_mode::PlaybackModeManager;
+334// Only the Android MediaSessionHandler resolves lockscreen skips; on other
+335// targets this would be an unused import.
+336#[cfg(target_os = "android")]
+337use player::seek::{resolve_skip_action, SkipAction};
+338use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
+339// NullBackend is used both for platforms without a native backend AND as a graceful
+340// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can
+341// still launch (browse library, manage downloads, see an error) instead of crashing.
+342use player::NullBackend;
+343
+344#[cfg(target_os = "linux")]
+345use player::MpvBackend;
+346use settings::VideoSettings;
+347use storage::Database;
+348use thumbnail::{CacheConfig as ThumbnailCacheConfig, ThumbnailCache};
+349
+350#[cfg(target_os = "android")]
+351use credentials::initialize_secure_storage;
+352
+353#[cfg(target_os = "android")]
+354use player::ExoPlayerBackend;
+355
+356#[cfg(target_os = "android")]
+357use player::{
+358    set_media_command_handler, set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
+359};
+360
+361/// Handler for media commands from Android MediaSession (lockscreen/notification controls).
+362///
+363/// Routes commands from the system media controls to the right place depending on
+364/// playback mode: in local mode it drives the local `PlayerController`; in remote
+365/// (cast) mode it forwards transport commands to the remote Jellyfin session so
+366/// the lockscreen can control whatever is casting. Stop while casting requests a
+367/// disconnect back to local playback.
+368#[cfg(target_os = "android")]
+369struct MediaSessionHandler {
+370    player: Arc<TokioMutex<PlayerController>>,
+371    playback_mode: Arc<PlaybackModeManager>,
+372    event_emitter: Arc<TauriEventEmitter>,
+373}
+374
+375#[cfg(target_os = "android")]
+376impl MediaSessionHandler {
+377    /// Forward a transport command to the active remote Jellyfin session.
+378    ///
+379    /// Runs async on the Tauri runtime because JNI callbacks arrive on arbitrary
+380    /// threads without a Tokio context.
+381    fn handle_remote_command(&self, command: &str, session_id: String) {
+382        use crate::player::{PlayerEventEmitter, PlayerStatusEvent};
+383
+384        // Stop while casting means "disconnect and resume locally". The frontend
+385        // owns the remote->local transfer (it reloads the item locally), so we
+386        // just signal intent.
+387        if command == "stop" {
+388            self.event_emitter
+389                .emit(PlayerStatusEvent::RemoteDisconnectRequested);
+390            return;
+391        }
+392
+393        let jellyfin_client = {
+394            let player = self.player.blocking_lock();
+395            player.jellyfin_client()
+396        };
+397        let command = command.to_string();
+398
+399        tauri::async_runtime::spawn(async move {
+400            let client = {
+401                let guard = match jellyfin_client.lock() {
+402                    Ok(g) => g,
+403                    Err(e) => {
+404                        error!("[MediaSession] Failed to lock Jellyfin client: {}", e);
+405                        return;
+406                    }
+407                };
+408                match guard.as_ref() {
+409                    Some(c) => c.clone(),
+410                    None => {
+411                        warn!("[MediaSession] No Jellyfin client for remote command");
+412                        return;
+413                    }
+414                }
+415            };
+416
+417            // Map lockscreen transport commands onto Jellyfin session commands.
+418            let result = match command.as_str() {
+419                "play" => client.send_session_command(session_id, "Unpause").await,
+420                "pause" => client.send_session_command(session_id, "Pause").await,
+421                "next" => client.send_session_command(session_id, "NextTrack").await,
+422                "previous" => {
+423                    client
+424                        .send_session_command(session_id, "PreviousTrack")
+425                        .await
+426                }
+427                cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
+428                    Ok(seconds) => {
+429                        let ticks = (seconds * 10_000_000.0) as i64;
+430                        client.session_seek(session_id, ticks).await
+431                    }
+432                    Err(_) => {
+433                        warn!("[MediaSession] Bad seek command: {}", command);
+434                        Ok(())
+435                    }
+436                },
+437                _ => {
+438                    warn!("[MediaSession] Unknown remote command: {}", command);
+439                    Ok(())
+440                }
+441            };
+442
+443            if let Err(e) = result {
+444                error!("[MediaSession] Remote command '{}' failed: {}", command, e);
+445            }
+446        });
+447    }
+448
+449    /// Drive the local player for a transport command.
+450    fn handle_local_command(&self, command: &str) {
+451        // A lockscreen scrub is an ABSOLUTE position — the scrubber shows the
+452        // whole episode — and resolving it during a background-audio handoff means
+453        // re-opening the stream, which is async. So it runs on the runtime and,
+454        // critically, is handled *before* the blocking lock below: taking that
+455        // guard and then spawning a task that waits for the same mutex would
+456        // deadlock the media session. (DR-159)
+457        if let Some(raw) = command.strip_prefix("seek:") {
+458            match raw.parse::<f64>() {
+459                Ok(position) => {
+460                    let player = self.player.clone();
+461                    tokio::spawn(async move {
+462                        let controller = player.lock().await;
+463                        if let Err(e) = controller.seek_absolute(position).await {
+464                            error!("[MediaSession] Seek to {:.1}s failed: {}", position, e);
+465                        }
+466                    });
+467                }
+468                Err(_) => warn!("[MediaSession] Bad seek command: {}", command),
+469            }
+470            return;
+471        }
+472
+473        // Skip means different things depending on what is actually playing, so
+474        // the decision belongs here rather than in the Kotlin that drew the
+475        // button: music advances the queue, while a video whose audio is running
+476        // through a background-audio handoff scrubs instead (UR-040). Routed
+477        // through the same spawn-and-seek path as "seek:" above, because
+478        // `seek_absolute` rebuilds the stream during a handoff and must not run
+479        // under the blocking lock (DR-159).
+480        //
+481        // TRACES: UR-040, UR-006 | DR-201
+482        if command == "next" || command == "previous" {
+483            let is_next = command == "next";
+484            let player = self.player.clone();
+485            tokio::spawn(async move {
+486                let controller = player.lock().await;
+487                let action = resolve_skip_action(
+488                    is_next,
+489                    controller.is_background_audio_active(),
+490                    controller.position(),
+491                    controller.duration(),
+492                );
+493                let label = if is_next { "next" } else { "previous" };
+494                let result: Result<(), String> = match action {
+495                    SkipAction::Advance => if is_next {
+496                        controller.next()
+497                    } else {
+498                        controller.previous()
+499                    }
+500                    .map_err(|e| e.to_string()),
+501                    SkipAction::SeekTo(position) => {
+502                        info!(
+503                            "[MediaSession] Background audio: '{}' scrubs to {:.1}s",
+504                            label, position
+505                        );
+506                        controller.seek_absolute(position).await
+507                    }
+508                };
+509                if let Err(e) = result {
+510                    error!("[MediaSession] Skip '{}' failed: {}", label, e);
+511                }
+512            });
+513            return;
+514        }
+515
+516        // Use blocking_lock since this is called from a non-async JNI callback
+517        let controller = self.player.blocking_lock();
+518
+519        let result = match command {
+520            "play" => controller.play(),
+521            "pause" => controller.pause(),
+522            "stop" => controller.stop(),
+523            _ => {
+524                warn!("[MediaSession] Unknown command: {}", command);
+525                Ok(())
+526            }
+527        };
+528
+529        if let Err(e) = result {
+530            error!("[MediaSession] Command '{}' failed: {}", command, e);
+531        }
+532    }
+533}
+534
+535#[cfg(target_os = "android")]
+536impl MediaCommandHandler for MediaSessionHandler {
+537    fn on_command(&self, command: &str) {
+538        match self.playback_mode.get_mode() {
+539            playback_mode::PlaybackMode::Remote { session_id } => {
+540                self.handle_remote_command(command, session_id);
+541            }
+542            _ => self.handle_local_command(command),
+543        }
+544    }
+545}
+546
+547/// Handler for remote volume changes from Android volume buttons when in remote playback mode.
+548///
+549/// Routes volume commands to the Jellyfin session via the playback mode manager.
+550#[cfg(target_os = "android")]
+551struct RemoteVolumeSessionHandler {
+552    playback_mode: Arc<PlaybackModeManager>,
+553}
+554
+555#[cfg(target_os = "android")]
+556impl RemoteVolumeHandler for RemoteVolumeSessionHandler {
+557    fn on_remote_volume_change(&self, command: &str, volume: i32) {
+558        log::info!("[RemoteVolume] Command: {}, Volume: {}", command, volume);
+559
+560        // Send the volume command to the remote session asynchronously
+561        let playback_mode = Arc::clone(&self.playback_mode);
+562        let command_str = command.to_string();
+563
+564        // Use tauri::async_runtime::spawn instead of tokio::spawn
+565        // JNI callbacks happen on arbitrary threads without a Tokio runtime
+566        log::info!("[RemoteVolume] Spawning async task to send volume command...");
+567        tauri::async_runtime::spawn(async move {
+568            log::info!("[RemoteVolume] Async task started, calling send_remote_volume_command...");
+569            match playback_mode
+570                .send_remote_volume_command(&command_str, volume)
+571                .await
+572            {
+573                Ok(_) => log::info!("[RemoteVolume] Volume command completed successfully"),
+574                Err(e) => log::error!("[RemoteVolume] Failed to send volume command: {}", e),
+575            }
+576            log::info!("[RemoteVolume] Async task completed");
+577        });
+578        log::info!("[RemoteVolume] Async task spawned, returning from JNI callback");
+579    }
+580}
+581
+582/// Payload emitted to the frontend when a native player backend fails to
+583/// initialize and the app falls back to a no-op backend.
+584#[derive(Clone, serde::Serialize)]
+585struct BackendInitError {
+586    platform: &'static str,
+587    backend: &'static str,
+588    message: String,
+589}
+590
+591/// Log a backend-initialization failure and notify the frontend, so the UI can
+592/// surface "playback unavailable" instead of the app hard-crashing.
+593fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str, message: String) {
+594    error!(
+595        "[INIT] Player backend '{}' failed to initialize: {}. Falling back to NullBackend (playback disabled).",
+596        backend, message
+597    );
+598    let _ = app_handle.emit(
+599        "backend-init-failed",
+600        BackendInitError {
+601            platform: std::env::consts::OS,
+602            backend,
+603            message,
+604        },
+605    );
+606}
+607
+608/// Create the appropriate player backend for the current platform.
+609// playback_reporter/position_throttler are consumed only by the native audio
+610// backends (mpv/exo); on platforms using the webview audio backend they're unused.
+611#[cfg_attr(
+612    not(any(target_os = "linux", target_os = "android")),
+613    allow(unused_variables)
+614)]
+615fn create_player_backend(
+616    app_handle: tauri::AppHandle,
+617    playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
+618    position_throttler: Arc<playback_reporting::EventThrottler>,
+619) -> Box<dyn PlayerBackend> {
+620    let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle.clone()));
+621
+622    #[cfg(target_os = "android")]
+623    {
+624        info!("Android platform detected - initializing ExoPlayer backend");
+625
+626        // Same precondition as the credential path: ndk_context must be
+627        // populated before it is read, and nothing outside this crate populates
+628        // it any more. Idempotent, so it does not matter which of the two runs
+629        // first. TRACES: UR-012 | DR-223
+630        if let Err(e) = crate::android_context::ensure_initialized() {
+631            log::error!("[INIT] Android context unavailable for the player: {e}");
+632        }
+633
+634        // Get the Android context via ndk-context
+635        let ctx = ndk_context::android_context();
+636
+637        // Get JavaVM and create JNI environment
+638        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
+639
+640        match vm {
+641            Ok(java_vm) => {
+642                match java_vm.attach_current_thread() {
+643                    Ok(mut env) => {
+644                        let context_obj =
+645                            unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
+646
+647                        match ExoPlayerBackend::new(
+648                            &mut env,
+649                            &context_obj,
+650                            _event_emitter.clone(),
+651                            playback_reporter.clone(),
+652                            position_throttler.clone(),
+653                        ) {
+654                            Ok(backend) => {
+655                                info!("Successfully initialized ExoPlayer backend for Android");
+656                                return Box::new(backend);
+657                            }
+658                            Err(e) => {
+659                                // Degrade gracefully instead of crashing the app.
+660                                emit_backend_init_failed(&app_handle, "exoplayer", e.to_string());
+661                                return Box::new(NullBackend::new());
+662                            }
+663                        }
+664                    }
+665                    Err(e) => {
+666                        emit_backend_init_failed(
+667                            &app_handle,
+668                            "exoplayer",
+669                            format!("attach JNI thread failed: {}", e),
+670                        );
+671                        return Box::new(NullBackend::new());
+672                    }
+673                }
+674            }
+675            Err(e) => {
+676                emit_backend_init_failed(
+677                    &app_handle,
+678                    "exoplayer",
+679                    format!("create JavaVM failed: {}", e),
+680                );
+681                return Box::new(NullBackend::new());
+682            }
+683        }
+684    }
+685
+686    // For Linux, use MPV backend for audio playback
+687    #[cfg(target_os = "linux")]
+688    {
+689        info!("Linux platform detected - initializing MPV backend for audio");
+690        match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
+691            Ok(backend) => {
+692                info!("Successfully initialized MPV backend for Linux");
+693                Box::new(backend)
+694            }
+695            Err(e) => {
+696                error!("\n========================================");
+697                error!("FATAL ERROR: Failed to initialize MPV backend");
+698                error!("========================================");
+699                error!("Error: {}", e);
+700                error!("\nCommon causes:");
+701                error!("  1. MPV is not installed");
+702                error!("     Solution: Install MPV using your package manager");
+703                error!("     - Arch/CachyOS: sudo pacman -S mpv");
+704                error!("     - Ubuntu/Debian: sudo apt install mpv libmpv-dev");
+705                error!("     - Fedora: sudo dnf install mpv mpv-libs-devel");
+706                error!("\n  2. MPV version mismatch (app was built with different libmpv version)");
+707                error!("     Solution: Rebuild the application");
+708                error!("     - cd src-tauri && cargo clean && cargo build --release");
+709                error!("\n  3. Audio system not working");
+710                error!("     Solution: Verify audio works with: pactl info");
+711                error!("\nAudio playback will NOT work until this is fixed.");
+712                error!("========================================\n");
+713
+714                // Degrade gracefully: launch with a no-op backend so the user can
+715                // still browse the library and manage downloads, and the frontend
+716                // can show a "playback unavailable" notice via this event.
+717                emit_backend_init_failed(&app_handle, "mpv", e.to_string());
+718                Box::new(NullBackend::new())
+719            }
+720        }
+721    }
+722
+723    // Platforms with no native audio backend (e.g. Windows): render audio-only
+724    // playback through a webview <audio> element (all video already renders in
+725    // the webview). Falls back to NullBackend only if the backend can't init.
+726    #[cfg(not(any(target_os = "linux", target_os = "android")))]
+727    {
+728        info!("No native audio backend for this platform - using webview <audio> backend");
+729        match player::WebviewAudioBackend::new(_event_emitter) {
+730            Ok(backend) => Box::new(backend),
+731            Err(e) => {
+732                emit_backend_init_failed(&app_handle, "webview-audio", e.to_string());
+733                Box::new(NullBackend::new())
+734            }
+735        }
+736    }
+737}
+738
+739/// Construct the tauri-specta command builder. Shared by `run()` and the
+740/// bindings-export test so the TypeScript bindings always match the handler.
+741/// What the engine built for this platform can do.
+742///
+743/// Declared per engine, not per category. ExoPlayer speaks HLS and can seek a
+744/// server-side transcode in place; mpv cannot, because its HLS demuxer will not
+745/// make the server produce segments from a new offset. Grouping them as "native
+746/// engines" gets that backwards — being native is not the property that
+747/// matters, speaking HLS is — and treating a category as a proxy for an ability
+748/// is exactly the inference DR-246 removed.
+749///
+750/// TRACES: UR-081 | DR-246
+751fn engine_capabilities() -> crate::player::media_player::Capabilities {
+752    #[cfg(target_os = "android")]
+753    {
+754        crate::player::media_player::Capabilities::exoplayer()
+755    }
+756    #[cfg(not(target_os = "android"))]
+757    {
+758        crate::player::media_player::Capabilities::mpv()
+759    }
+760}
+761
+762fn specta_builder() -> Builder<tauri::Wry> {
+763    Builder::<tauri::Wry>::new()
+764        // Throw on error so generated `commands.*` return Promise<T> and throw,
+765        // matching the existing frontend's invoke() try/catch convention.
+766        .error_handling(tauri_specta::ErrorHandlingMode::Throw)
+767        .events(tauri_specta::collect_events![
+768            crate::player::events::PlayerStatusEvent
+769        ])
+770        .commands(tauri_specta::collect_commands![
+771            // Player commands
+772            player_play_item,
+773            player_background_action,
+774            player_enter_background_audio,
+775            player_exit_background_audio,
+776            player_play_queue,
+777            player_play_album_track,
+778            player_play_tracks,
+779            player_play,
+780            player_pause,
+781            player_toggle,
+782            player_stop,
+783            player_next,
+784            player_previous,
+785            player_seek,
+786            player_seek_video,
+787            player_set_volume,
+788            player_toggle_mute,
+789            player_set_audio_track,
+790            player_switch_audio_track,
+791            player_set_subtitle_track,
+792            player_toggle_shuffle,
+793            player_cycle_repeat,
+794            player_get_status,
+795            player_get_queue,
+796            player_get_capabilities,
+797            player_add_to_queue,
+798            player_add_track_by_id,
+799            player_add_tracks_by_ids,
+800            player_remove_from_queue,
+801            player_move_in_queue,
+802            player_skip_to,
+803            player_set_audio_settings,
+804            player_get_audio_settings,
+805            player_get_eq_presets,
+806            player_set_video_settings,
+807            player_get_video_settings,
+808            player_get_streaming_qualities,
+809            player_set_stream_quality,
+810            // Sleep timer and autoplay commands
+811            player_set_sleep_timer,
+812            player_cancel_sleep_timer,
+813            player_get_sleep_timer,
+814            player_get_autoplay_settings,
+815            player_set_autoplay_settings,
+816            player_cancel_autoplay_countdown,
+817            player_play_next_episode,
+818            player_on_playback_ended,
+819            player_recover_stream,
+820            player_report_state,
+821            player_report_position,
+822            player_report_media_loaded,
+823            // Preload commands
+824            player_local_media_path,
+825            player_preload_upcoming,
+826            player_set_cache_config,
+827            player_get_cache_config,
+828            // Jellyfin reporting commands
+829            player_configure_jellyfin,
+830            player_disable_jellyfin,
+831            // Session management commands
+832            player_get_session,
+833            player_dismiss_session,
+834            // Remote session control commands
+835            remote_play_on_session,
+836            remote_send_command,
+837            remote_session_seek,
+838            remote_session_set_volume,
+839            remote_session_toggle_mute,
+840            // LMS multi-room sync group commands
+841            lms_get_sync_groups,
+842            lms_create_sync_group,
+843            lms_unsync_player,
+844            lms_dissolve_sync_group,
+845            // Session polling commands
+846            sessions_set_polling_hint,
+847            sessions_poll_now,
+848            // Playback mode commands
+849            playback_mode_get_current,
+850            playback_mode_set,
+851            playback_mode_is_transferring,
+852            playback_mode_transfer_to_remote,
+853            playback_mode_get_remote_status,
+854            playback_mode_transfer_to_local,
+855            playback_mode_set_transferring,
+856            // Playback reporting commands
+857            playback_reporter_init,
+858            playback_reporter_destroy,
+859            playback_report_start,
+860            playback_report_progress,
+861            playback_report_stopped,
+862            playback_mark_played,
+863            // Auth commands
+864            auth_initialize,
+865            auth_connect_to_server,
+866            auth_login,
+867            auth_verify_session,
+868            auth_logout,
+869            auth_get_session,
+870            auth_set_session,
+871            auth_start_verification,
+872            auth_stop_verification,
+873            auth_reauthenticate,
+874            // Device commands
+875            device_get_id,
+876            device_set_id,
+877            // Connectivity commands
+878            connectivity_check_server,
+879            connectivity_set_server_url,
+880            connectivity_get_status,
+881            connectivity_start_monitoring,
+882            connectivity_stop_monitoring,
+883            connectivity_mark_reachable,
+884            connectivity_mark_unreachable,
+885            // Storage commands
+886            storage_init,
+887            storage_get_path,
+888            storage_get_size,
+889            storage_get_security_status,
+890            storage_save_server,
+891            storage_get_servers,
+892            storage_delete_server,
+893            storage_save_user,
+894            storage_get_users,
+895            storage_set_active_user,
+896            storage_get_active_user,
+897            storage_get_active_session,
+898            storage_get_access_token,
+899            storage_delete_user,
+900            // Playback progress commands
+901            storage_update_playback_progress,
+902            storage_update_playback_context,
+903            storage_mark_played,
+904            storage_set_watched,
+905            storage_get_playback_progress,
+906            storage_mark_synced,
+907            storage_toggle_favorite,
+908            // Download commands
+909            download_item,
+910            download_item_and_start,
+911            download_album,
+912            download_video,
+913            download_series,
+914            download_season,
+915            get_downloads,
+916            pause_download,
+917            resume_download,
+918            cancel_download,
+919            delete_download,
+920            delete_all_downloads,
+921            delete_album_downloads,
+922            delete_downloads_under,
+923            clear_stale_downloads,
+924            get_download_storage_stats,
+925            mark_download_completed,
+926            mark_download_failed,
+927            media_local_url,
+928            media_local_selection,
+929            start_download,
+930            enqueue_download,
+931            enqueue_video_downloads,
+932            sync_full_catalog,
+933            catalog_sync_status,
+934            set_show_server_catalog,
+935            // Library browsing preferences (UR-076 / DR-209)
+936            library_get_settings,
+937            library_set_settings,
+938            library_get_exclusion_candidates,
+939            resume_queued_downloads,
+940            get_download_manager_stats,
+941            set_max_concurrent_downloads,
+942            get_smart_cache_stats,
+943            update_smart_cache_config,
+944            get_smart_cache_config,
+945            // WiFi-only download gate (UR-053)
+946            set_network_state,
+947            get_downloads_allowed,
+948            get_album_recommendations,
+949            get_album_affinity_status,
+950            // Pinning commands
+951            pin_item,
+952            unpin_item,
+953            is_item_pinned,
+954            // Offline commands
+955            offline_is_available,
+956            offline_get_items,
+957            offline_search,
+958            // Offline cache commands
+959            storage_get_libraries,
+960            storage_get_items,
+961            storage_get_item,
+962            storage_search_items,
+963            storage_save_library,
+964            storage_save_item,
+965            storage_get_pending_sync_count,
+966            // Sync queue commands
+967            sync_queue_mutation,
+968            sync_get_pending,
+969            sync_mark_processing,
+970            sync_mark_completed,
+971            sync_mark_failed,
+972            sync_get_pending_count,
+973            sync_process_pending,
+974            sync_cleanup_completed,
+975            sync_clear_user,
+976            // Thumbnail cache and image commands
+977            thumbnail_get_cached,
+978            thumbnail_save,
+979            thumbnail_get_stats,
+980            thumbnail_set_limit,
+981            thumbnail_clear_cache,
+982            thumbnail_delete_item,
+983            image_get_url,
+984            // People cache commands
+985            storage_save_person,
+986            storage_get_person,
+987            storage_save_item_people,
+988            storage_get_item_people,
+989            // Series audio preferences
+990            storage_save_series_audio_preference,
+991            storage_get_series_audio_preference,
+992            // Repository commands
+993            repository_create,
+994            repository_destroy,
+995            repository_get_libraries,
+996            repository_get_items,
+997            repository_get_item,
+998            repository_get_downloaded_libraries,
+999            repository_get_downloaded_items,
+1000            repository_get_download_disk_usage,
+1001            repository_jray_actors_at,
+1002            repository_get_latest_items,
+1003            repository_get_resume_items,
+1004            repository_get_next_up_episodes,
+1005            repository_get_series_episodes,
+1006            repository_get_series_current_episode,
+1007            repository_clear_watch_history,
+1008            repository_get_recently_played_audio,
+1009            repository_get_resume_movies,
+1010            repository_get_rediscover_albums,
+1011            repository_get_genres,
+1012            repository_search,
+1013            repository_get_playback_info,
+1014            repository_get_video_stream_url,
+1015            repository_get_stream_selection,
+1016            repository_get_audio_stream_url,
+1017            repository_get_audio_only_stream_url_for_video,
+1018            repository_get_live_tv_channels,
+1019            repository_get_channels,
+1020            repository_open_live_stream,
+1021            repository_report_playback_start,
+1022            repository_report_playback_progress,
+1023            repository_report_playback_stopped,
+1024            repository_get_image_url,
+1025            repository_mark_favorite,
+1026            repository_unmark_favorite,
+1027            repository_get_favorites,
+1028            repository_get_person,
+1029            repository_get_items_by_person,
+1030            repository_get_similar_items,
+1031            repository_get_subtitle_url,
+1032            repository_get_video_download_url,
+1033            // Playlist commands
+1034            playlist_create,
+1035            playlist_delete,
+1036            playlist_rename,
+1037            playlist_get_items,
+1038            playlist_add_items,
+1039            playlist_remove_items,
+1040            playlist_move_item,
+1041            // Diagnostics commands
+1042            // TRACES: UR-078 | DR-218
+1043            diagnostics_get_info,
+1044            diagnostics_set_level,
+1045            diagnostics_export,
+1046            // Conversion commands
+1047            format_time_seconds,
+1048            format_time_seconds_long,
+1049            convert_ticks_to_seconds,
+1050            calc_progress,
+1051            convert_percent_to_volume,
+1052        ])
+1053}
+1054
+1055/// Configure GStreamer (the media backend behind WebKitGTK's HTML5 `<video>`
+1056/// element on Linux) to prefer hardware-accelerated VAAPI decoding when the
+1057/// host provides it, falling back to software decoding otherwise.
+1058///
+1059/// All variables are only set if the user has not already exported them, so an
+1060/// explicit override (e.g. forcing software decode for debugging) is respected.
+1061/// They must be applied before WebKitGTK builds its GStreamer pipeline, hence the
+1062/// call at the very top of `run()`.
+1063#[cfg(target_os = "linux")]
+1064fn enable_linux_hardware_video_decoding() {
+1065    // Boost the rank of the modern stateless VAAPI decoders (gst-plugins-bad
+1066    // `va` plugin) so GStreamer selects them ahead of the software decoders. The
+1067    // `MAX` rank wins decoder autoplugging when the hardware/driver supports the
+1068    // codec; unsupported codecs simply fall through to software.
+1069    let rank_overrides = "vah264dec:MAX,vah265dec:MAX,vavp9dec:MAX,vaav1dec:MAX,\
+1070                          vampeg2dec:MAX,vavp8dec:MAX";
+1071
+1072    set_env_if_unset("GST_PLUGIN_FEATURE_RANK", rank_overrides);
+1073
+1074    // Ensure WebKit keeps GStreamer's hardware/DMABUF video path enabled. Setting
+1075    // this to "0" would force software decoding, so only default it to "1".
+1076    set_env_if_unset("WEBKIT_GST_ENABLE_HW_VIDEO_DECODER", "1");
+1077
+1078    info!("[INIT] Linux hardware video decoding (VAAPI) enabled where supported");
+1079
+1080    log_available_vaapi_decoders();
+1081}
+1082
+1083/// Probe (via `gst-inspect-1.0`, which ships with GStreamer) which VAAPI hardware
+1084/// video decoders GStreamer can actually load on this host, and log the result so
+1085/// it is clear at startup whether hardware decoding is genuinely available or
+1086/// whether playback will fall back to software.
+1087#[cfg(target_os = "linux")]
+1088fn log_available_vaapi_decoders() {
+1089    const HW_DECODERS: &[&str] = &[
+1090        "vah264dec",
+1091        "vah265dec",
+1092        "vavp9dec",
+1093        "vaav1dec",
+1094        "vampeg2dec",
+1095        "vavp8dec",
+1096    ];
+1097
+1098    let available: Vec<&str> = HW_DECODERS
+1099        .iter()
+1100        .copied()
+1101        .filter(|name| {
+1102            std::process::Command::new("gst-inspect-1.0")
+1103                .arg(name)
+1104                .stdout(std::process::Stdio::null())
+1105                .stderr(std::process::Stdio::null())
+1106                .status()
+1107                .map(|s| s.success())
+1108                .unwrap_or(false)
+1109        })
+1110        .collect();
+1111
+1112    if available.is_empty() {
+1113        log::warn!(
+1114            "[INIT] No VAAPI hardware video decoders found via gst-inspect-1.0; \
+1115             video will use software decoding. Install the GStreamer 'va' plugin \
+1116             (gst-plugins-bad) and a VAAPI driver to enable hardware decoding."
+1117        );
+1118    } else {
+1119        info!(
+1120            "[INIT] VAAPI hardware video decoders available to GStreamer: {}",
+1121            available.join(", ")
+1122        );
+1123    }
+1124}
+1125
+1126#[cfg(target_os = "linux")]
+1127fn set_env_if_unset(key: &str, value: &str) {
+1128    if std::env::var_os(key).is_none() {
+1129        // SAFETY: called once at startup before any threads that read the
+1130        // environment (WebKitGTK/GStreamer) are spawned.
+1131        std::env::set_var(key, value);
+1132    }
+1133}
+1134
+1135/// Cached thumbnails are handed to the webview as asset-protocol URLs by
+1136/// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
+1137/// `http://asset.localhost/…` on Windows/Android). Tauri only answers that
+1138/// origin when the `protocol-asset` cargo feature is compiled in *and*
+1139/// `app.security.assetProtocol.enable` is set in `tauri.conf.json`. Both are
+1140/// required together: with either missing the URL resolves to nothing and the
+1141/// webview reports `NETWORK_NO_SOURCE`, which is how offline video came to fail
+1142/// silently.
+1143///
+1144/// The scope is `$APPDATA/thumbnails/**`, not the storage root: downloaded media
+1145/// moved to the loopback media server in DR-137, so `imageCache` is the only
+1146/// remaining `convertFileSrc` caller and the database and the encrypted-token
+1147/// fallback file — which share that root — never need to be readable by the
+1148/// webview. Widen it only if something other than thumbnails starts resolving
+1149/// through `convertFileSrc` again.
+1150///
+1151/// TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198
+1152/// Build the logging plugin.
+1153///
+1154/// Replaces the previous `env_logger` init, which wrote to **stdout only**. That
+1155/// was invisible to anyone who launched from a desktop icon, and worse than
+1156/// useless on Android: stdout is not logcat, so the Rust backend produced no
+1157/// visible output at all on the platform carrying the hardest bugs in this
+1158/// project's history (the autoplay deadlock, the truncated-stream restart, the
+1159/// background-audio stall). tauri-plugin-log routes to logcat there for free.
+1160///
+1161/// Three decisions worth keeping:
+1162///
+1163/// * **Every line goes through `redact` first.** A credential must never reach
+1164///   disk, not merely be stripped later when a bundle is exported — a file on
+1165///   the device is already the disclosure.
+1166/// * **The size cap is deliberate.** `RotationStrategy::KeepAll` would let a
+1167///   long-running session fill a phone. One rotation keeps yesterday's evidence
+1168///   without unbounded growth.
+1169/// * **The level is read from disk.** Someone reproducing a bug needs debug
+1170///   logging to survive the restart that reproduces it.
+1171///
+1172/// TRACES: UR-078 | DR-218
+1173fn build_log_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
+1174    use tauri_plugin_log::{Target, TargetKind};
+1175
+1176    let mut targets = vec![
+1177        Target::new(TargetKind::Stdout),
+1178        Target::new(TargetKind::LogDir {
+1179            file_name: Some("jellytau".to_string()),
+1180        }),
+1181    ];
+1182
+1183    // Rust lines in the webview console, so a developer sees both halves of the
+1184    // app in one place. Dev only -- in a release build this would ship backend
+1185    // logging into a console the user can open.
+1186    if cfg!(debug_assertions) {
+1187        targets.push(Target::new(TargetKind::Webview));
+1188    }
+1189
+1190    tauri_plugin_log::Builder::new()
+1191        .targets(targets)
+1192        .level(log::LevelFilter::Info)
+1193        .max_file_size(5 * 1024 * 1024)
+1194        .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne)
+1195        .format(|out, message, record| {
+1196            out.finish(format_args!(
+1197                "[{}][{}] {}",
+1198                record.level(),
+1199                record.target(),
+1200                crate::utils::diagnostics::redact(&message.to_string())
+1201            ))
+1202        })
+1203        .build()
+1204}
+1205
+1206#[cfg_attr(mobile, tauri::mobile_entry_point)]
+1207pub fn run() {
+1208    // Crash capture before anything else, so a panic during startup is recorded
+1209    // rather than vanishing with the process.
+1210    //
+1211    // TRACES: UR-078 | DR-218
+1212    crate::utils::diagnostics::install_panic_hook();
+1213
+1214    // On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
+1215    // GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
+1216    // when available so video transcoding/decoding does not fall back to the CPU.
+1217    // These must be set before WebKitGTK initializes its GStreamer pipeline.
+1218    #[cfg(target_os = "linux")]
+1219    enable_linux_hardware_video_decoding();
+1220
+1221    // NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
+1222    // test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
+1223    // `.export()` here would try to write `../src/lib/api/bindings.ts` at app
+1224    // startup, which panics on devices (e.g. Android) where that path doesn't exist.
+1225    let builder = specta_builder();
+1226    let invoke_handler = builder.invoke_handler();
+1227
+1228    tauri::Builder::default()
+1229        .plugin(build_log_plugin())
+1230        .plugin(tauri_plugin_opener::init())
+1231        .plugin(tauri_plugin_os::init())
+1232        .invoke_handler(invoke_handler)
+1233        .setup(move |app| {
+1234            // Mount tauri-specta events so PlayerStatusEvent can be emitted to and
+1235            // listened for on the frontend via the generated bindings.
+1236            builder.mount_events(app);
+1237
+1238
+1239            // In-app update, desktop only.
+1240            //
+1241            // Registered here rather than in the builder chain above because a
+1242            // `#[cfg]` attribute cannot be attached to one link of a method
+1243            // chain -- this block is the shape Tauri's own docs use.
+1244            //
+1245            // Android is excluded on purpose: tauri-plugin-updater cannot
+1246            // replace an installed APK, and the frontend offers the releases
+1247            // page there instead.
+1248            //
+1249            // Re-apply the log level the user last chose. Without this the
+1250            // picker would only affect the running session -- and the whole
+1251            // point of a persisted level is that somebody reproducing a bug
+1252            // keeps debug logging across the restart that reproduces it.
+1253            //
+1254            // TRACES: UR-078 | DR-218
+1255            if let Ok(config_dir) = app.path().app_config_dir() {
+1256                if let Some(level) = crate::commands::diagnostics::stored_level(&config_dir) {
+1257                    log::set_max_level(level);
+1258                    log::info!("[DIAG] restored log level from settings: {level}");
+1259                }
+1260            }
+1261
+1262            // TRACES: UR-077 | DR-217
+1263            #[cfg(desktop)]
+1264            {
+1265                app.handle()
+1266                    .plugin(tauri_plugin_updater::Builder::new().build())?;
+1267                app.handle().plugin(tauri_plugin_process::init())?;
+1268            }
+1269
+1270            // Initialize database with proper app data directory
+1271            // Check for test mode environment variable first
+1272            let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
+1273                let test_path = std::path::PathBuf::from(test_data_dir);
+1274                info!("[INIT] Using test data directory: {:?}", test_path);
+1275                test_path.join("jellytau.db")
+1276            } else {
+1277                app
+1278                    .path()
+1279                    .app_data_dir()
+1280                    .expect("Failed to get app data directory")
+1281                    .join("jellytau.db")
+1282            };
+1283
+1284            info!("[INIT] Initializing database at: {:?}", db_path);
+1285
+1286            // Create the directory if it doesn't exist
+1287            if let Some(parent) = db_path.parent() {
+1288                info!("[INIT] Creating database directory: {:?}", parent);
+1289                match std::fs::create_dir_all(parent) {
+1290                    Ok(_) => info!("[INIT] Database directory ready"),
+1291                    Err(e) => {
+1292                        error!("[INIT ERROR] Failed to create database directory: {}", e);
+1293                        panic!("Failed to create database directory: {}", e);
+1294                    }
+1295                }
+1296            }
+1297
+1298            info!("[INIT] Opening database...");
+1299            let database = match Database::open(&db_path) {
+1300                Ok(db) => {
+1301                    info!("[INIT] Database initialized successfully");
+1302                    db
+1303                }
+1304                Err(e) => {
+1305                    error!("[INIT ERROR] Failed to initialize database: {}", e);
+1306                    panic!("Failed to initialize database: {}", e);
+1307                }
+1308            };
+1309            let db_wrapper = DatabaseWrapper(Mutex::new(database));
+1310            app.manage(db_wrapper);
+1311
+1312            // On Android, initialize SecureStorage BEFORE creating CredentialStore
+1313            #[cfg(target_os = "android")]
+1314            {
+1315                // Publish the JavaVM and Application into ndk_context first.
+1316                //
+1317                // Everything below reads that global. tao used to populate it
+1318                // and stopped doing so in 0.35 (Tauri 2.11), at which point the
+1319                // first read here aborted the process on launch. See
+1320                // android_context.rs. A failure is logged rather than fatal:
+1321                // credentials then fall back to the encrypted-file path, which
+1322                // is a supported degraded mode -- unlike aborting.
+1323                //
+1324                // TRACES: UR-012 | DR-223
+1325                if let Err(e) = crate::android_context::ensure_initialized() {
+1326                    log::error!("[INIT] Android context unavailable: {e}");
+1327                    log::error!("[INIT] Secure credential storage will fall back to the encrypted file.");
+1328                }
+1329
+1330                info!("[INIT] Initializing Android SecureStorage for credentials...");
+1331                let ctx = ndk_context::android_context();
+1332                let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
+1333
+1334                match vm {
+1335                    Ok(java_vm) => {
+1336                        match java_vm.attach_current_thread() {
+1337                            Ok(mut env) => {
+1338                                let context_obj = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
+1339
+1340                                match initialize_secure_storage(&mut env, &context_obj) {
+1341                                    Ok(()) => {
+1342                                        info!("[INIT] Android SecureStorage initialized successfully");
+1343                                    }
+1344                                    Err(e) => {
+1345                                        warn!("[INIT WARNING] Failed to initialize SecureStorage: {}. Credentials will use encrypted file fallback.", e);
+1346                                    }
+1347                                }
+1348                            }
+1349                            Err(e) => {
+1350                                warn!("[INIT WARNING] Failed to attach JNI thread: {}. Credentials will use encrypted file fallback.", e);
+1351                            }
+1352                        }
+1353                    }
+1354                    Err(e) => {
+1355                        warn!("[INIT WARNING] Failed to create JavaVM: {}. Credentials will use encrypted file fallback.", e);
+1356                    }
+1357                }
+1358            }
+1359
+1360            // Initialize credential store (keyring with encrypted file fallback)
+1361            info!("[INIT] Initializing credential store...");
+1362            let credential_store = CredentialStore::new();
+1363            let creds_wrapper = CredentialStoreWrapper(Mutex::new(credential_store));
+1364            app.manage(creds_wrapper);
+1365
+1366            // Create shared reporter and throttler Arc wrappers before backend/controller
+1367            info!("[INIT] Creating shared playback reporting infrastructure...");
+1368            let playback_reporter = Arc::new(tokio::sync::Mutex::new(None));
+1369            let position_throttler = Arc::new(playback_reporting::EventThrottler::new());
+1370
+1371            // Create player backend with access to AppHandle for event emission
+1372            info!("[INIT] Creating player backend...");
+1373            let backend = create_player_backend(
+1374                app.handle().clone(),
+1375                playback_reporter.clone(),
+1376                position_throttler.clone(),
+1377            );
+1378            // Attached *after* the backend exists: the mpv handle is registered
+1379            // during its construction, and doing this in the order the code
+1380            // used to read produced "no mpv handle" every time — the surface was
+1381            // built before there was anything to draw from.
+1382            // Native video surface: put a GL area under Tauri's webview so mpv
+1383            // can draw beneath the controls (UR-080 / DR-231).
+1384            //
+1385            // 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
+1386            // first click. `tauri-runtime-wry`'s undecorated-resizing handler
+1387            // walks a hard-coded two-hop path on every button press in the
+1388            // webview:
+1389            //
+1390            //     webview.parent()          // "This one should be GtkBox"
+1391            //            .parent()          // ...and this one the GtkWindow
+1392            //            .downcast::<gtk::Window>().unwrap()
+1393            //
+1394            // Wrapping the webview in a GtkOverlay makes that chain
+1395            // webview → GtkOverlay → GtkBox, the downcast fails, and because the
+1396            // panic is non-unwinding it aborts the process. The decoration check
+1397            // that would otherwise make this handler inert runs *after* the
+1398            // unwrap, so no window configuration avoids it.
+1399            //
+1400            // This is the "only place Tauri-specific behaviour could still bite"
+1401            // that the spike named as the untested half of G1. It bites. The
+1402            // surface attaches perfectly and then dies on interaction, so
+1403            // "attached successfully" in the log is not the gate — a click is.
+1404            //
+1405            // Kept behind an env var rather than deleted so the next attempt has
+1406            // something to iterate on:  JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
+1407            //
+1408            // TRACES: UR-080 | DR-231
+1409            #[cfg(target_os = "linux")]
+1410            if crate::player::native_video::enabled() {
+1411                use tauri::Manager;
+1412                log::warn!(
+1413                    "[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
+1414                     video surface (mpv drawn behind the webview, no reparenting)"
+1415                );
+1416                if let Some(window) = app.get_webview_window("main") {
+1417                    match window.default_vbox() {
+1418                        Ok(vbox) => {
+1419                            let handle = crate::player::mpv_backend::registered_handle();
+1420                            if crate::player::video_surface::attach(&vbox, handle) {
+1421                                info!("[INIT] Native video surface attached");
+1422                            } else {
+1423                                log::warn!("[INIT] Native video surface unavailable");
+1424                            }
+1425                        }
+1426                        Err(e) => {
+1427                            log::warn!("[INIT] No GTK vbox for the main window: {e}")
+1428                        }
+1429                    }
+1430                }
+1431            }
+1432
+1433            // Every engine reaches the controller through the one contract.
+1434            // `LegacyPlayer` carries the not-yet-ported ones across unchanged,
+1435            // so this port swaps a seam rather than four implementations.
+1436            // TRACES: UR-081 | DR-245
+1437            let player_controller = PlayerController::new(
+1438                Box::new(crate::player::LegacyPlayer::new(
+1439                    backend,
+1440                    engine_capabilities(),
+1441                )),
+1442                playback_reporter.clone(),
+1443                position_throttler.clone(),
+1444            );
+1445
+1446            // Wire up event emitter for sleep timer and autoplay notifications
+1447            let event_emitter = Arc::new(TauriEventEmitter::new(app.handle().clone()));
+1448            player_controller.set_event_emitter(event_emitter.clone());
+1449
+1450            let player_arc = Arc::new(TokioMutex::new(player_controller));
+1451
+1452            // On Android, register the player controller for autoplay decisions.
+1453            // The MediaSession (lockscreen) handler is set up later, once the
+1454            // playback mode manager exists, so it can route to remote sessions.
+1455            #[cfg(target_os = "android")]
+1456            {
+1457                player::android::set_player_controller(player_arc.clone());
+1458            }
+1459
+1460            let player_state = PlayerStateWrapper(player_arc.clone());
+1461            app.manage(player_state);
+1462
+1463            // Initialize media session manager
+1464            info!("[INIT] Initializing media session manager...");
+1465            let session_manager = MediaSessionManager::new();
+1466            let session_wrapper = MediaSessionManagerWrapper(Mutex::new(session_manager));
+1467            app.manage(session_wrapper);
+1468
+1469            // Initialize playback mode manager
+1470            info!("[INIT] Initializing playback mode manager...");
+1471            let jellyfin_client = {
+1472                let player = player_arc.blocking_lock();
+1473                player.jellyfin_client()
+1474            };
+1475            let playback_mode_manager = playback_mode::PlaybackModeManager::new(
+1476                jellyfin_client.clone(),
+1477                player_arc.clone(),
+1478            );
+1479            let playback_mode_arc = Arc::new(playback_mode_manager);
+1480            // Broadcast mode changes so the frontend's mirror store reconciles to
+1481            // this authoritative one (prevents remote/local control desync).
+1482            playback_mode_arc.set_event_emitter(event_emitter.clone());
+1483            let playback_mode_wrapper = PlaybackModeManagerWrapper(playback_mode_arc.clone());
+1484            app.manage(playback_mode_wrapper);
+1485
+1486            // Initialize session poller manager for remote session polling
+1487            info!("[INIT] Initializing session poller manager...");
+1488            let session_poller = session_poller::SessionPollerManager::new(
+1489                jellyfin_client,
+1490                playback_mode_arc.clone(),
+1491            );
+1492            session_poller.set_event_emitter(event_emitter.clone());
+1493            // Note: start() is deferred until after the connectivity monitor is
+1494            // created below, so the poller can report reachability from its first
+1495            // poll (it drives offline detection + recovery while the user is idle).
+1496            let session_poller_arc = Arc::new(session_poller);
+1497            let session_poller_wrapper = SessionPollerWrapper(session_poller_arc.clone());
+1498            app.manage(session_poller_wrapper);
+1499
+1500            // On Android, set up the MediaSession (lockscreen) handler and the
+1501            // remote volume handler. Both need the playback mode manager so they
+1502            // can route to the active remote session while casting.
+1503            #[cfg(target_os = "android")]
+1504            {
+1505                info!("[INIT] Setting up MediaSession handler for lockscreen controls...");
+1506                let media_handler = Arc::new(MediaSessionHandler {
+1507                    player: player_arc.clone(),
+1508                    playback_mode: playback_mode_arc.clone(),
+1509                    event_emitter: event_emitter.clone(),
+1510                });
+1511                set_media_command_handler(media_handler);
+1512
+1513                info!("[INIT] Setting up remote volume handler for Android...");
+1514                let handler = Arc::new(RemoteVolumeSessionHandler {
+1515                    playback_mode: playback_mode_arc.clone(),
+1516                });
+1517                set_remote_volume_handler(handler);
+1518            }
+1519
+1520            // Initialize video settings with defaults
+1521            let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
+1522            app.manage(video_settings);
+1523
+1524            // Restore the persisted streaming bandwidth ceiling. Deferred to the
+1525            // async runtime because the read is async, and ordered after the
+1526            // wrapper above because it writes into it. Until it lands, streams
+1527            // are uncapped — the pre-existing behaviour — and no playback can
+1528            // have started this early anyway (login happens after setup).
+1529            //
+1530            // TRACES: UR-074 | DR-162
+1531            {
+1532                let handle = app.handle().clone();
+1533                tauri::async_runtime::spawn(async move {
+1534                    crate::commands::restore_streaming_quality(&handle).await;
+1535                });
+1536            }
+1537
+1538            // Restore the folders the user hid from browsing, for the same
+1539            // reason and in the same way. Until it lands nothing is hidden —
+1540            // the pre-existing behaviour — and no query can have run this early.
+1541            //
+1542            // TRACES: UR-076 | DR-209
+1543            {
+1544                let handle = app.handle().clone();
+1545                tauri::async_runtime::spawn(async move {
+1546                    crate::commands::restore_library_settings(&handle).await;
+1547                });
+1548            }
+1549
+1550            // Initialize thumbnail cache
+1551            info!("[INIT] Initializing thumbnail cache...");
+1552            let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
+1553                std::path::PathBuf::from(test_data_dir)
+1554            } else {
+1555                app
+1556                    .path()
+1557                    .app_data_dir()
+1558                    .expect("Failed to get app data directory")
+1559            };
+1560            let thumbnail_cache = ThumbnailCache::new(app_data_dir.clone(), ThumbnailCacheConfig::default());
+1561            let thumbnail_wrapper = ThumbnailCacheWrapper(Arc::new(thumbnail_cache));
+1562            app.manage(thumbnail_wrapper);
+1563
+1564            // Initialize smart cache for preloading
+1565            info!("[INIT] Initializing smart cache...");
+1566            let smart_cache = SmartCache::new(SmartCacheConfig::default());
+1567            let smart_cache_wrapper = SmartCacheWrapper(Mutex::new(smart_cache));
+1568            app.manage(smart_cache_wrapper);
+1569
+1570            // Serve downloaded media over loopback HTTP. The webview cannot
+1571            // stream a large file through the asset protocol (see media_server),
+1572            // so local playback resolves its URL from here instead.
+1573            // TRACES: UR-071 | DR-137
+1574            info!("[INIT] Starting local media server...");
+1575            let media_server = match media_server::start(app_data_dir.clone()) {
+1576                Ok(s) => Some(s),
+1577                Err(e) => {
+1578                    // Not fatal: streaming still works, and the command reports
+1579                    // a clear error if local playback is attempted.
+1580                    error!("[INIT ERROR] Local media server failed to start: {}", e);
+1581                    None
+1582                }
+1583            };
+1584            app.manage(media_server::MediaServerWrapper(media_server));
+1585
+1586            // Initialize download manager
+1587            info!("[INIT] Initializing download manager...");
+1588            let download_dir = app_data_dir.join("downloads");
+1589            let download_manager = DownloadManager::new(download_dir);
+1590            let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
+1591            app.manage(download_manager_wrapper);
+1592
+1593            // Current network transport, for the WiFi-only download gate (UR-053).
+1594            // Defaults to unmetered ethernet so desktop is never gated; Android
+1595            // overwrites it via set_network_state as soon as the UI starts.
+1596            app.manage(commands::download::NetworkStateWrapper(
+1597                download::network::NetworkStateHandle::new(),
+1598            ));
+1599
+1600            // Initialize connectivity monitor
+1601            info!("[INIT] Initializing connectivity monitor...");
+1602            let http_config = HttpConfig::default();
+1603            let http_client = HttpClient::new(http_config)
+1604                .expect("Failed to create HTTP client");
+1605            let mut connectivity_monitor = ConnectivityMonitor::new(http_client);
+1606            connectivity_monitor.set_app_handle(app.handle().clone());
+1607
+1608            // Wire the connectivity reporter into the session poller so its
+1609            // continuous background polls drive reachability (offline detection
+1610            // + recovery) even when the user isn't browsing, then start it.
+1611            session_poller_arc.set_connectivity_reporter(connectivity_monitor.reporter());
+1612            session_poller_arc.start();
+1613
+1614            // Wrap in Arc for sharing with AuthManager
+1615            let connectivity_arc = Arc::new(tokio::sync::Mutex::new(connectivity_monitor));
+1616            let connectivity_wrapper = ConnectivityMonitorWrapper(connectivity_arc.clone());
+1617            app.manage(connectivity_wrapper);
+1618
+1619            // Initialize auth manager
+1620            info!("[INIT] Initializing auth manager...");
+1621            let auth_http_config = HttpConfig::default();
+1622            let auth_http_client = HttpClient::new(auth_http_config)
+1623                .expect("Failed to create HTTP client for auth");
+1624            let mut auth_manager = AuthManager::new(auth_http_client);
+1625
+1626            // Give auth manager a reference to connectivity monitor
+1627            auth_manager.set_connectivity_monitor(connectivity_arc.clone());
+1628
+1629            let auth_manager_wrapper = AuthManagerWrapper(Arc::new(auth_manager));
+1630            app.manage(auth_manager_wrapper);
+1631
+1632            // Initialize session verifier wrapper (initially empty)
+1633            info!("[INIT] Initializing session verifier wrapper...");
+1634            let session_verifier_wrapper = SessionVerifierWrapper(Arc::new(tokio::sync::Mutex::new(None)));
+1635            app.manage(session_verifier_wrapper);
+1636
+1637            // Initialize repository manager
+1638            info!("[INIT] Initializing repository manager...");
+1639            let repository_manager = commands::RepositoryManager::new();
+1640            let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager);
+1641            app.manage(repository_manager_wrapper);
+1642
+1643            // Initialize playback reporter wrapper. This MUST share the same Arc
+1644            // the player controller and MPV progress loop report through (created
+1645            // above at `playback_reporter`), otherwise `playback_reporter_init`
+1646            // would populate a dead, parallel Option and no Start/Progress/Stopped
+1647            // would ever reach Jellyfin.
+1648            info!("[INIT] Initializing playback reporter wrapper...");
+1649            let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone());
+1650            app.manage(playback_reporter_wrapper);
+1651
+1652            // Keep the local search index fresh. Ownership of *when* to re-index
+1653            // sits here rather than in the frontend: it is sync policy over
+1654            // domain data, and a startup-only trigger left a long session
+1655            // searching a stale catalog.
+1656            // TRACES: UR-065 | DR-109, IR-030
+1657            info!("[INIT] Starting background catalog indexer...");
+1658            commands::catalog::spawn_catalog_indexer(app.handle().clone());
+1659
+1660            // Push favourite toggles made while the server was unreachable, on
+1661            // every reconnect. In Rust rather than the frontend so it runs
+1662            // whether or not the screen that made the change is still mounted.
+1663            // TRACES: UR-069 | DR-120
+1664            info!("[INIT] Starting favourites drain...");
+1665            commands::favorites::spawn_favorites_drain(app.handle().clone());
+1666
+1667            // Push playback reports queued while the server was unreachable.
+1668            // Without this the `sync_queue` rows the reporter writes offline
+1669            // are never sent and the offline banner's count only grows.
+1670            // TRACES: UR-025, UR-002 | DR-131
+1671            info!("[INIT] Starting sync-queue drain...");
+1672            commands::sync_drain::spawn_sync_queue_drain(app.handle().clone());
+1673
+1674            info!("[INIT] Application setup completed successfully");
+1675            Ok(())
+1676        })
+1677        .run(tauri::generate_context!())
+1678        .expect("error while running tauri application");
+1679}
+1680
+1681#[cfg(test)]
+1682mod specta_bindings {
+1683    /// Generates `src/lib/api/bindings.ts`. Run with `cargo test export_typescript_bindings`.
+1684    #[test]
+1685    fn export_typescript_bindings() {
+1686        super::specta_builder()
+1687            .export(
+1688                specta_typescript::Typescript::default()
+1689                    .bigint(specta_typescript::BigIntExportBehavior::Number),
+1690                "../src/lib/api/bindings.ts",
+1691            )
+1692            .expect("failed to export typescript bindings");
+1693    }
+1694}
+
\ 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..45772a64 --- /dev/null +++ b/api/src/jellytau_lib/playback_mode/mod.rs.html @@ -0,0 +1,1310 @@ +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                // Audio and direct-URL items never negotiate a transport.
+1129                transport: None,
+1130                id: id.to_string(),
+1131                title: format!("Track {}", id),
+1132                name: Some(format!("Track {}", id)),
+1133                artist: Some("Test Artist".to_string()),
+1134                album: Some("Test Album".to_string()),
+1135                album_name: Some("Test Album".to_string()),
+1136                album_id: Some("album_123".to_string()),
+1137                artist_items: None,
+1138                artists: Some(vec!["Test Artist".to_string()]),
+1139                primary_image_tag: None,
+1140                image_id: None,
+1141                item_type: Some("Audio".to_string()),
+1142                playlist_id: None,
+1143                duration: Some(180.0),
+1144                artwork_url: None,
+1145                media_type: MediaType::Audio,
+1146                source: MediaSource::Remote {
+1147                    stream_url: format!("http://example.com/{}.mp3", id),
+1148                    jellyfin_item_id: jellyfin_id.to_string(),
+1149                },
+1150                video_codec: None,
+1151                needs_transcoding: false,
+1152                video_width: None,
+1153                video_height: None,
+1154                subtitles: vec![],
+1155                series_id: None,
+1156                server_id: None,
+1157            }
+1158        }
+1159
+1160        fn create_test_item_local(id: &str) -> MediaItem {
+1161            MediaItem {
+1162                // Audio and direct-URL items never negotiate a transport.
+1163                transport: None,
+1164                id: id.to_string(),
+1165                title: format!("Local Track {}", id),
+1166                name: Some(format!("Local Track {}", id)),
+1167                artist: Some("Test Artist".to_string()),
+1168                album: None,
+1169                album_name: None,
+1170                album_id: None,
+1171                artist_items: None,
+1172                artists: Some(vec!["Test Artist".to_string()]),
+1173                primary_image_tag: None,
+1174                image_id: None,
+1175                item_type: Some("Audio".to_string()),
+1176                playlist_id: None,
+1177                duration: Some(180.0),
+1178                artwork_url: None,
+1179                media_type: MediaType::Audio,
+1180                source: MediaSource::DirectUrl {
+1181                    url: format!("http://example.com/{}.mp3", id),
+1182                },
+1183                video_codec: None,
+1184                needs_transcoding: false,
+1185                video_width: None,
+1186                video_height: None,
+1187                subtitles: vec![],
+1188                series_id: None,
+1189                server_id: None,
+1190            }
+1191        }
+1192
+1193        /// Test extracting all Jellyfin track IDs from album
+1194        ///
+1195        /// Verifies that all individual track IDs are extracted, not just the album ID.
+1196        ///
+1197        /// @req-test: UR-010 - Control playback of Jellyfin remote sessions
+1198        /// @req-test: DR-003 - Playback mode manager (Jellyfin ID extraction)
+1199        /// @req-test: IR-012 - Jellyfin Sessions API for remote playback control
+1200        #[test]
+1201        fn test_extract_all_jellyfin_ids_from_album() {
+1202            // Simulate an album with 5 tracks - all should be extracted
+1203            let items: Vec<MediaItem> = (1..=5)
+1204                .map(|i| {
+1205                    create_test_item_with_jellyfin_id(
+1206                        &format!("track_{}", i),
+1207                        &format!("jf_track_{}", i),
+1208                    )
+1209                })
+1210                .collect();
+1211
+1212            let manager = super::PlaybackModeManager::new(
+1213                Arc::new(Mutex::new(None)),
+1214                Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
+1215            );
+1216
+1217            let result = manager.extract_jellyfin_ids(&items, 2);
+1218            assert!(result.is_ok());
+1219
+1220            let (ids, index) = result.unwrap();
+1221
+1222            // All 5 track IDs should be extracted (not just the album ID)
+1223            assert_eq!(ids.len(), 5, "All 5 track IDs should be extracted");
+1224            assert_eq!(ids[0], "jf_track_1");
+1225            assert_eq!(ids[1], "jf_track_2");
+1226            assert_eq!(ids[2], "jf_track_3");
+1227            assert_eq!(ids[3], "jf_track_4");
+1228            assert_eq!(ids[4], "jf_track_5");
+1229
+1230            // Index should point to track 3 (original index 2)
+1231            assert_eq!(index, 2, "Current index should be preserved");
+1232        }
+1233
+1234        /// Test extracting Jellyfin IDs filters out local items
+1235        ///
+1236        /// @req-test: UR-010 - Control playback of remote sessions (local filtering)
+1237        /// @req-test: DR-003 - Playback mode manager (local item filtering)
+1238        #[test]
+1239        fn test_extract_filters_local_items() {
+1240            // Mix of Jellyfin and local items - only Jellyfin items should be extracted
+1241            let items = vec![
+1242                create_test_item_with_jellyfin_id("1", "jf_1"),
+1243                create_test_item_local("2"), // Local, no Jellyfin ID
+1244                create_test_item_with_jellyfin_id("3", "jf_3"),
+1245                create_test_item_with_jellyfin_id("4", "jf_4"),
+1246            ];
+1247
+1248            let manager = super::PlaybackModeManager::new(
+1249                Arc::new(Mutex::new(None)),
+1250                Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
+1251            );
+1252
+1253            // Playing track 3 (index 2 in original, should become index 1 after filtering)
+1254            let result = manager.extract_jellyfin_ids(&items, 2);
+1255            assert!(result.is_ok());
+1256
+1257            let (ids, index) = result.unwrap();
+1258
+1259            // Only 3 Jellyfin tracks should be extracted
+1260            assert_eq!(ids.len(), 3);
+1261            assert_eq!(ids[0], "jf_1");
+1262            assert_eq!(ids[1], "jf_3");
+1263            assert_eq!(ids[2], "jf_4");
+1264
+1265            // Index should be adjusted (track 3 is now at position 1)
+1266            assert_eq!(index, 1);
+1267        }
+1268
+1269        /// Test extraction fails when current item is local
+1270        ///
+1271        /// @req-test: DR-003 - Playback mode manager (error handling)
+1272        /// @req-test: UR-010 - Control playback of remote sessions (validation)
+1273        #[test]
+1274        fn test_extract_fails_when_current_item_is_local() {
+1275            // Current item has no Jellyfin ID - should fail
+1276            let items = vec![
+1277                create_test_item_with_jellyfin_id("1", "jf_1"),
+1278                create_test_item_local("2"), // Local, no Jellyfin ID
+1279                create_test_item_with_jellyfin_id("3", "jf_3"),
+1280            ];
+1281
+1282            let manager = super::PlaybackModeManager::new(
+1283                Arc::new(Mutex::new(None)),
+1284                Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
+1285            );
+1286
+1287            // Playing the local track (index 1) should fail
+1288            let result = manager.extract_jellyfin_ids(&items, 1);
+1289            assert!(result.is_err());
+1290            assert!(result.unwrap_err().contains("not from Jellyfin"));
+1291        }
+1292
+1293        /// Test extraction fails on empty queue
+1294        ///
+1295        /// @req-test: DR-003 - Playback mode manager (edge case: empty queue)
+1296        #[test]
+1297        fn test_extract_empty_queue() {
+1298            let items: Vec<MediaItem> = vec![];
+1299
+1300            let manager = super::PlaybackModeManager::new(
+1301                Arc::new(Mutex::new(None)),
+1302                Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
+1303            );
+1304
+1305            let result = manager.extract_jellyfin_ids(&items, 0);
+1306            assert!(result.is_err());
+1307        }
+1308    }
+1309}
+
\ 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..4689d3f4 --- /dev/null +++ b/api/src/jellytau_lib/player/backend.rs.html @@ -0,0 +1,590 @@ +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/// Forward the trait through a box.
+253///
+254/// `Box<dyn PlayerBackend>` does not implement `PlayerBackend` on its own, so
+255/// without this the boxed engine built at the composition root cannot be handed
+256/// to anything generic over the trait — `LegacyPlayer` in particular.
+257impl PlayerBackend for Box<dyn PlayerBackend> {
+258    fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
+259        (**self).load(media)
+260    }
+261    fn play(&mut self) -> Result<(), PlayerError> {
+262        (**self).play()
+263    }
+264    fn pause(&mut self) -> Result<(), PlayerError> {
+265        (**self).pause()
+266    }
+267    fn stop(&mut self) -> Result<(), PlayerError> {
+268        (**self).stop()
+269    }
+270    fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
+271        (**self).seek(position)
+272    }
+273    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
+274        (**self).set_volume(volume)
+275    }
+276    fn position(&self) -> f64 {
+277        (**self).position()
+278    }
+279    fn duration(&self) -> Option<f64> {
+280        (**self).duration()
+281    }
+282    fn state(&self) -> PlayerState {
+283        (**self).state()
+284    }
+285    fn volume(&self) -> f32 {
+286        (**self).volume()
+287    }
+288    fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
+289        (**self).set_audio_settings(settings)
+290    }
+291    fn audio_settings(&self) -> AudioSettings {
+292        (**self).audio_settings()
+293    }
+294    fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
+295        (**self).set_audio_track(stream_index)
+296    }
+297    fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
+298        (**self).set_subtitle_track(stream_index)
+299    }
+300}
+301
+302#[cfg(test)]
+303mod tests {
+304    use super::*;
+305
+306    /// Test NullBackend volume default value
+307    /// TRACES: UR-016 | DR-004 | UT-026
+308    #[test]
+309    fn test_null_backend_volume_default() {
+310        let backend = NullBackend::new();
+311        assert_eq!(backend.volume(), 1.0);
+312    }
+313
+314    /// Test NullBackend set volume
+315    ///
+316    /// @req-test: UT-027 - NullBackend set volume
+317    /// @req-test: UR-016 - Change system settings while playing (volume)
+318    #[test]
+319    fn test_null_backend_set_volume() {
+320        let mut backend = NullBackend::new();
+321        backend.set_volume(0.5).unwrap();
+322        assert_eq!(backend.volume(), 0.5);
+323    }
+324
+325    /// Test NullBackend volume clamping (high)
+326    ///
+327    /// @req-test: UT-028 - NullBackend volume clamping (high/low)
+328    /// @req-test: UR-016 - Change system settings while playing (volume)
+329    #[test]
+330    fn test_null_backend_volume_clamping_high() {
+331        let mut backend = NullBackend::new();
+332        backend.set_volume(1.5).unwrap();
+333        assert_eq!(backend.volume(), 1.0);
+334    }
+335
+336    /// Test NullBackend volume clamping (low)
+337    ///
+338    /// @req-test: UT-028 - NullBackend volume clamping (high/low)
+339    /// @req-test: UR-016 - Change system settings while playing (volume)
+340    #[test]
+341    fn test_null_backend_volume_clamping_low() {
+342        let mut backend = NullBackend::new();
+343        backend.set_volume(-0.5).unwrap();
+344        assert_eq!(backend.volume(), 0.0);
+345    }
+346
+347    /// Test NullBackend volume boundary values
+348    ///
+349    /// @req-test: UT-029 - NullBackend volume boundary values
+350    /// @req-test: UR-016 - Change system settings while playing (volume)
+351    #[test]
+352    fn test_null_backend_volume_boundary() {
+353        let mut backend = NullBackend::new();
+354
+355        backend.set_volume(0.0).unwrap();
+356        assert_eq!(backend.volume(), 0.0);
+357
+358        backend.set_volume(1.0).unwrap();
+359        assert_eq!(backend.volume(), 1.0);
+360    }
+361
+362    /// Test NullBackend audio settings default values
+363    ///
+364    /// @req-test: DR-034 - Crossfade engine
+365    /// @req-test: DR-035 - Gapless playback
+366    /// @req-test: DR-036 - Volume normalization
+367    #[test]
+368    fn test_null_backend_audio_settings_default() {
+369        let backend = NullBackend::new();
+370        let settings = backend.audio_settings();
+371        assert_eq!(settings.crossfade_duration, 0.0);
+372        assert!(settings.gapless_playback);
+373        assert!(!settings.normalize_volume);
+374    }
+375
+376    /// Test NullBackend set audio settings
+377    ///
+378    /// @req-test: DR-034 - Crossfade engine with configurable duration
+379    /// @req-test: DR-035 - Gapless playback between sequential tracks
+380    /// @req-test: DR-036 - Volume normalization with preset levels
+381    #[test]
+382    fn test_null_backend_set_audio_settings() {
+383        use crate::settings::VolumeLevel;
+384
+385        let mut backend = NullBackend::new();
+386        let settings = AudioSettings {
+387            crossfade_duration: 5.0,
+388            gapless_playback: false,
+389            normalize_volume: true,
+390            volume_level: VolumeLevel::Loud,
+391            ..Default::default()
+392        };
+393
+394        backend.set_audio_settings(&settings).unwrap();
+395
+396        let result = backend.audio_settings();
+397        assert_eq!(result.crossfade_duration, 5.0);
+398        assert!(!result.gapless_playback);
+399        assert!(result.normalize_volume);
+400        assert_eq!(result.volume_level, VolumeLevel::Loud);
+401    }
+402
+403    /// Test NullBackend audio settings crossfade clamping to 12s max
+404    ///
+405    /// @req-test: DR-034 - Crossfade engine with configurable duration (0-12s)
+406    #[test]
+407    fn test_null_backend_audio_settings_crossfade_clamping() {
+408        let mut backend = NullBackend::new();
+409        let settings = AudioSettings {
+410            crossfade_duration: 20.0,
+411            ..Default::default()
+412        };
+413
+414        backend.set_audio_settings(&settings).unwrap();
+415        assert_eq!(backend.audio_settings().crossfade_duration, 12.0);
+416    }
+417
+418    /// Test NullBackend seek updates position
+419    ///
+420    /// @req-test: UR-005 - Control media playback (scrub operation)
+421    /// @req-test: DR-004 - PlayerBackend trait
+422    #[test]
+423    fn test_null_backend_seek_updates_position() {
+424        use crate::player::media::{MediaItem, MediaSource, MediaType};
+425
+426        let mut backend = NullBackend::new();
+427
+428        // Create a test media item
+429        let media = MediaItem {
+430            // Audio and direct-URL items never negotiate a transport.
+431            transport: None,
+432            id: "test_media".to_string(),
+433            title: "Test Track".to_string(),
+434            name: Some("Test Track".to_string()),
+435            artist: Some("Test Artist".to_string()),
+436            album: Some("Test Album".to_string()),
+437            album_name: Some("Test Album".to_string()),
+438            album_id: None,
+439            artist_items: None,
+440            artists: Some(vec!["Test Artist".to_string()]),
+441            primary_image_tag: None,
+442            image_id: None,
+443            item_type: Some("Audio".to_string()),
+444            playlist_id: None,
+445            duration: Some(180.0),
+446            artwork_url: None,
+447            media_type: MediaType::Audio,
+448            source: MediaSource::DirectUrl {
+449                url: "http://example.com/test.mp3".to_string(),
+450            },
+451            video_codec: None,
+452            needs_transcoding: false,
+453            video_width: None,
+454            video_height: None,
+455            subtitles: vec![],
+456            series_id: None,
+457            server_id: None,
+458        };
+459
+460        // Load and play the media
+461        backend.load(&media).unwrap();
+462        backend.play().unwrap();
+463
+464        // Verify initial position
+465        assert_eq!(backend.position(), 0.0);
+466
+467        // Seek to 30 seconds
+468        backend.seek(30.0).unwrap();
+469        assert_eq!(backend.position(), 30.0);
+470
+471        // Seek to 60 seconds
+472        backend.seek(60.0).unwrap();
+473        assert_eq!(backend.position(), 60.0);
+474
+475        // Seek backward
+476        backend.seek(15.0).unwrap();
+477        assert_eq!(backend.position(), 15.0);
+478    }
+479
+480    /// Test NullBackend seek while paused
+481    ///
+482    /// @req-test: UR-005 - Control media playback (scrub while paused)
+483    /// @req-test: DR-001 - Player state machine (seeking from paused state)
+484    #[test]
+485    fn test_null_backend_seek_while_paused() {
+486        use crate::player::media::{MediaItem, MediaSource, MediaType};
+487
+488        let mut backend = NullBackend::new();
+489
+490        let media = MediaItem {
+491            // Audio and direct-URL items never negotiate a transport.
+492            transport: None,
+493            id: "test_media".to_string(),
+494            title: "Test Track".to_string(),
+495            name: Some("Test Track".to_string()),
+496            artist: Some("Test Artist".to_string()),
+497            album: Some("Test Album".to_string()),
+498            album_name: Some("Test Album".to_string()),
+499            album_id: None,
+500            artist_items: None,
+501            artists: Some(vec!["Test Artist".to_string()]),
+502            primary_image_tag: None,
+503            image_id: None,
+504            item_type: Some("Audio".to_string()),
+505            playlist_id: None,
+506            duration: Some(180.0),
+507            artwork_url: None,
+508            media_type: MediaType::Audio,
+509            source: MediaSource::DirectUrl {
+510                url: "http://example.com/test.mp3".to_string(),
+511            },
+512            video_codec: None,
+513            needs_transcoding: false,
+514            video_width: None,
+515            video_height: None,
+516            subtitles: vec![],
+517            series_id: None,
+518            server_id: None,
+519        };
+520
+521        // Load media (starts paused)
+522        backend.load(&media).unwrap();
+523
+524        // Verify state is paused
+525        assert!(matches!(backend.state(), PlayerState::Paused { .. }));
+526
+527        // Seek while paused
+528        backend.seek(45.0).unwrap();
+529        assert_eq!(backend.position(), 45.0);
+530
+531        // Verify still paused
+532        assert!(matches!(backend.state(), PlayerState::Paused { .. }));
+533    }
+534
+535    /// Test NullBackend position updates reflected in state
+536    ///
+537    /// @req-test: DR-001 - Player state machine (position tracking)
+538    /// @req-test: UR-005 - Control media playback (position accuracy)
+539    #[test]
+540    fn test_null_backend_position_updates_in_state() {
+541        use crate::player::media::{MediaItem, MediaSource, MediaType};
+542
+543        let mut backend = NullBackend::new();
+544
+545        let media = MediaItem {
+546            // Audio and direct-URL items never negotiate a transport.
+547            transport: None,
+548            id: "test_media".to_string(),
+549            title: "Test Track".to_string(),
+550            name: Some("Test Track".to_string()),
+551            artist: Some("Test Artist".to_string()),
+552            album: Some("Test Album".to_string()),
+553            album_name: Some("Test Album".to_string()),
+554            album_id: None,
+555            artist_items: None,
+556            artists: Some(vec!["Test Artist".to_string()]),
+557            primary_image_tag: None,
+558            image_id: None,
+559            item_type: Some("Audio".to_string()),
+560            playlist_id: None,
+561            duration: Some(180.0),
+562            artwork_url: None,
+563            media_type: MediaType::Audio,
+564            source: MediaSource::DirectUrl {
+565                url: "http://example.com/test.mp3".to_string(),
+566            },
+567            video_codec: None,
+568            needs_transcoding: false,
+569            video_width: None,
+570            video_height: None,
+571            subtitles: vec![],
+572            series_id: None,
+573            server_id: None,
+574        };
+575
+576        backend.load(&media).unwrap();
+577        backend.play().unwrap();
+578
+579        // Seek to 30 seconds
+580        backend.seek(30.0).unwrap();
+581
+582        // Verify the state reflects the new position
+583        if let PlayerState::Playing { position, .. } = backend.state() {
+584            assert_eq!(position, 30.0);
+585        } else {
+586            panic!("Expected Playing state");
+587        }
+588    }
+589}
+
\ 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/legacy_player.rs.html b/api/src/jellytau_lib/player/legacy_player.rs.html new file mode 100644 index 00000000..55a599f6 --- /dev/null +++ b/api/src/jellytau_lib/player/legacy_player.rs.html @@ -0,0 +1,259 @@ +legacy_player.rs - sourceSkip to main content

jellytau_lib/player/
legacy_player.rs

1//! A [`MediaPlayer`] over the old [`PlayerBackend`] trait.
+2//!
+3//! Two purposes.
+4//!
+5//! **Migration.** Engines not yet ported — ExoPlayer, the webview element, the
+6//! null backend — keep working while `PlayerController` moves onto the new
+7//! contract (DR-245). Without this the port would have to land all four engines
+8//! at once.
+9//!
+10//! **Evidence.** It reproduces exactly what every caller used to do: `load`,
+11//! then `play`, then `seek` for a start position. Running the conformance suite
+12//! against it therefore shows the old path failing the cases the new one passes,
+13//! on the same engine and the same media — which is the difference between
+14//! asserting that a design was wrong and demonstrating it.
+15//!
+16//! It is deliberately a faithful reproduction, not a fixed-up one. Making it
+17//! pass would defeat the point.
+18//!
+19//! TRACES: UR-081 | DR-245
+20
+21use std::time::Duration;
+22
+23use super::backend::{PlayerBackend, PlayerError};
+24use super::media_player::{
+25    duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot,
+26};
+27use super::state::PlayerState;
+28
+29pub struct LegacyPlayer<B: PlayerBackend> {
+30    inner: B,
+31    /// Declared at construction: this wrapper is generic over engines with very
+32    /// different abilities, and only the composition root knows which one it
+33    /// just built. Guessing here would reintroduce exactly the inference DR-238
+34    /// removed.
+35    capabilities: Capabilities,
+36    /// The old trait has no notion of "opening", so this is the best the wrapper
+37    /// can do: it knows an item was handed over, not whether the engine is ready
+38    /// for one. That gap is the whole problem.
+39    has_item: bool,
+40}
+41
+42impl<B: PlayerBackend> LegacyPlayer<B> {
+43    pub fn new(inner: B, capabilities: Capabilities) -> Self {
+44        Self {
+45            inner,
+46            capabilities,
+47            has_item: false,
+48        }
+49    }
+50}
+51
+52impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
+53    /// Load, play, then seek — the sequence every caller used to write.
+54    ///
+55    /// The seek is issued immediately, because a caller has no way to know when
+56    /// the engine becomes ready. On an engine whose load is asynchronous it
+57    /// fails and is discarded, and playback begins at zero: DR-241, reproduced.
+58    fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
+59        self.inner.load(&req.media)?;
+60        self.has_item = true;
+61        if req.autoplay {
+62            self.inner.play()?;
+63        }
+64        if !req.start.is_zero() {
+65            // Faithfully ignoring the failure, exactly as the old callers did.
+66            let _ = self.inner.seek(req.start.as_secs_f64());
+67        }
+68        Ok(())
+69    }
+70
+71    fn play(&mut self) -> Result<(), PlayerError> {
+72        self.inner.play()
+73    }
+74
+75    fn pause(&mut self) -> Result<(), PlayerError> {
+76        self.inner.pause()
+77    }
+78
+79    fn close(&mut self) -> Result<(), PlayerError> {
+80        self.has_item = false;
+81        self.inner.stop()
+82    }
+83
+84    fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
+85        self.inner.seek(to.as_secs_f64())
+86    }
+87
+88    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
+89        self.inner.set_volume(volume)
+90    }
+91
+92    /// The old trait has no mute. Folding it into volume would lose the user's
+93    /// level, so this reports unsupported rather than pretending.
+94    fn set_muted(&mut self, _muted: bool) -> Result<(), PlayerError> {
+95        Err(PlayerError {
+96            message: "mute is not supported by this backend".to_string(),
+97        })
+98    }
+99
+100    fn set_rate(&mut self, _rate: f64) -> Result<(), PlayerError> {
+101        Err(PlayerError {
+102            message: "playback rate is not supported by this backend".to_string(),
+103        })
+104    }
+105
+106    fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
+107        self.inner.set_audio_track(index.unwrap_or(-1))
+108    }
+109
+110    fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
+111        self.inner.set_subtitle_track(index)
+112    }
+113
+114    fn snapshot(&self) -> PlaybackSnapshot {
+115        let phase = match self.inner.state() {
+116            _ if !self.has_item => Phase::Idle,
+117            PlayerState::Playing { .. } => Phase::Playing,
+118            PlayerState::Paused { .. } => Phase::Paused,
+119            PlayerState::Idle => Phase::Idle,
+120            PlayerState::Error { error, .. } => Phase::Failed(error),
+121            // `Loading` is the closest the old trait comes to an opening state,
+122            // but it is set once the engine has accepted the item rather than
+123            // while it is still accepting it — which is precisely the window it
+124            // cannot describe.
+125            PlayerState::Loading { .. } | PlayerState::Seeking { .. } => Phase::Ready,
+126        };
+127        PlaybackSnapshot {
+128            phase,
+129            position: duration_from_secs(self.inner.position()).unwrap_or(Duration::ZERO),
+130            duration: self.inner.duration().and_then(duration_from_secs),
+131            seekable: true,
+132            volume: self.inner.volume(),
+133            muted: false,
+134            rate: 1.0,
+135            audio_track: None,
+136            subtitle_track: None,
+137        }
+138    }
+139
+140    fn set_audio_settings(
+141        &mut self,
+142        settings: &crate::settings::AudioSettings,
+143    ) -> Result<(), PlayerError> {
+144        self.inner.set_audio_settings(settings)
+145    }
+146
+147    fn audio_settings(&self) -> crate::settings::AudioSettings {
+148        self.inner.audio_settings()
+149    }
+150
+151    fn capabilities(&self) -> Capabilities {
+152        self.capabilities
+153    }
+154}
+155
+156#[cfg(test)]
+157mod tests {
+158    use super::*;
+159    use crate::player::media::MediaItem;
+160    use crate::settings::AudioSettings;
+161
+162    /// A backend that answers badly, on purpose.
+163    ///
+164    /// Every engine the conformance suite drives reports sane numbers, which is
+165    /// why it passed while a real one did not: ExoPlayer returns
+166    /// `C.TIME_UNSET` — `Long::MIN_VALUE`, about -9.2e15 seconds — for any
+167    /// stream whose length it does not know, and the adapter converted that
+168    /// straight into a `Duration` and panicked the whole backend.
+169    ///
+170    /// The old `PlayerBackend` contract is a plain `f64`. It never promised
+171    /// finite, never promised positive, and nothing enforced it. So this is the
+172    /// engine the suites were missing.
+173    struct HostileBackend {
+174        duration: f64,
+175        position: f64,
+176    }
+177
+178    impl PlayerBackend for HostileBackend {
+179        fn load(&mut self, _media: &MediaItem) -> Result<(), PlayerError> {
+180            Ok(())
+181        }
+182        fn play(&mut self) -> Result<(), PlayerError> {
+183            Ok(())
+184        }
+185        fn pause(&mut self) -> Result<(), PlayerError> {
+186            Ok(())
+187        }
+188        fn stop(&mut self) -> Result<(), PlayerError> {
+189            Ok(())
+190        }
+191        fn seek(&mut self, _position: f64) -> Result<(), PlayerError> {
+192            Ok(())
+193        }
+194        fn set_volume(&mut self, _volume: f32) -> Result<(), PlayerError> {
+195            Ok(())
+196        }
+197        fn position(&self) -> f64 {
+198            self.position
+199        }
+200        fn duration(&self) -> Option<f64> {
+201            Some(self.duration)
+202        }
+203        fn state(&self) -> PlayerState {
+204            PlayerState::Idle
+205        }
+206        fn volume(&self) -> f32 {
+207            1.0
+208        }
+209        fn set_audio_settings(&mut self, _s: &AudioSettings) -> Result<(), PlayerError> {
+210            Ok(())
+211        }
+212        fn audio_settings(&self) -> AudioSettings {
+213            AudioSettings::default()
+214        }
+215        fn set_audio_track(&mut self, _i: i32) -> Result<(), PlayerError> {
+216            Ok(())
+217        }
+218        fn set_subtitle_track(&mut self, _i: Option<i32>) -> Result<(), PlayerError> {
+219            Ok(())
+220        }
+221    }
+222
+223    fn hostile(duration: f64, position: f64) -> LegacyPlayer<HostileBackend> {
+224        LegacyPlayer::new(
+225            HostileBackend { duration, position },
+226            crate::player::media_player::Capabilities::mpv(),
+227        )
+228    }
+229
+230    /// Reading an engine that answers badly must not take the process down.
+231    ///
+232    /// This is DR-252 as a test. It fails — by panicking — against the adapter
+233    /// as originally written, which is the property the conformance suite could
+234    /// not have: it only ever drove engines that behave.
+235    ///
+236    /// TRACES: UR-005 | DR-252 | UT-223
+237    #[test]
+238    fn test_snapshot_survives_an_engine_that_answers_badly() {
+239        // The exact value ExoPlayer reports for an unknown length.
+240        let s = hostile(-9_223_372_036_854_776.0, 0.0).snapshot();
+241        assert_eq!(s.duration, None, "a negative duration is not a duration");
+242
+243        for bad in [f64::NAN, f64::NEG_INFINITY, f64::INFINITY, -1.0, 0.0] {
+244            let s = hostile(bad, bad).snapshot();
+245            assert_eq!(s.duration, None, "{bad} should not become a duration");
+246            assert_eq!(
+247                s.position,
+248                Duration::ZERO,
+249                "{bad} should not become a position"
+250            );
+251        }
+252
+253        // And a well-behaved engine still works.
+254        let s = hostile(6997.024, 540.0).snapshot();
+255        assert_eq!(s.duration, Some(Duration::from_secs_f64(6997.024)));
+256        assert_eq!(s.position, Duration::from_secs_f64(540.0));
+257    }
+258}
+
\ 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..57222bcc --- /dev/null +++ b/api/src/jellytau_lib/player/media.rs.html @@ -0,0 +1,660 @@ +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    /// How this item's stream is fetched, as the backend decided it.
+119    ///
+120    /// Carried on the queue item so a later seek/reload does not have to guess.
+121    /// `None` for items queued by a path that never negotiated (audio tracks,
+122    /// direct URLs) and for anything queued before this field existed, where the
+123    /// caller falls back to `needs_transcoding` — every transcode this app
+124    /// requests is HLS (DR-140), so that fallback is exact rather than a guess.
+125    ///
+126    /// TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
+127    #[serde(default)]
+128    pub transport: Option<crate::repository::Transport>,
+129
+130    /// Video width in pixels
+131    #[serde(default)]
+132    pub video_width: Option<u32>,
+133    /// Video height in pixels
+134    #[serde(default)]
+135    pub video_height: Option<u32>,
+136    /// Available subtitle tracks
+137    #[serde(default)]
+138    pub subtitles: Vec<SubtitleTrack>,
+139    /// Series ID (for TV show episodes) - used for series audio preferences
+140    #[serde(default)]
+141    pub series_id: Option<String>,
+142    /// Server ID - used for series audio preferences
+143    #[serde(default)]
+144    pub server_id: Option<String>,
+145}
+146
+147#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+148#[serde(rename_all = "lowercase")]
+149pub enum MediaType {
+150    Audio,
+151    Video,
+152}
+153
+154/// TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003
+155#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
+156#[serde(tag = "type", rename_all = "lowercase")]
+157#[specta(rename = "PlayerMediaSource")]
+158pub enum MediaSource {
+159    /// Streaming from Jellyfin server
+160    Remote {
+161        stream_url: String,
+162        jellyfin_item_id: String,
+163    },
+164    /// Downloaded/cached locally
+165    Local {
+166        file_path: PathBuf,
+167        /// Original Jellyfin ID for sync-back
+168        jellyfin_item_id: Option<String>,
+169    },
+170    /// Direct URL (e.g., channel plugins)
+171    DirectUrl { url: String },
+172}
+173
+174impl MediaItem {
+175    /// The URL or path an engine should open.
+176    ///
+177    /// TRACES: UR-081 | DR-245
+178    pub fn playable_url(&self) -> String {
+179        match &self.source {
+180            MediaSource::Remote { stream_url, .. } => stream_url.clone(),
+181            MediaSource::Local { file_path, .. } => file_path.to_string_lossy().into_owned(),
+182            MediaSource::DirectUrl { url } => url.clone(),
+183        }
+184    }
+185}
+186
+187impl MediaItem {
+188    /// Get the Jellyfin item ID if available
+189    pub fn jellyfin_id(&self) -> Option<&str> {
+190        match &self.source {
+191            MediaSource::Remote {
+192                jellyfin_item_id, ..
+193            } => Some(jellyfin_item_id),
+194            MediaSource::Local {
+195                jellyfin_item_id, ..
+196            } => jellyfin_item_id.as_deref(),
+197            MediaSource::DirectUrl { .. } => None,
+198        }
+199    }
+200
+201    /// Get the playback URL or file path
+202    ///
+203    /// Only available on Android where ExoPlayer needs direct URL access
+204    #[cfg(target_os = "android")]
+205    pub fn playback_url(&self) -> String {
+206        match &self.source {
+207            MediaSource::Remote { stream_url, .. } => stream_url.clone(),
+208            MediaSource::Local { file_path, .. } => file_path.to_string_lossy().to_string(),
+209            MediaSource::DirectUrl { url } => url.clone(),
+210        }
+211    }
+212}
+213
+214impl MediaItem {
+215    /// A minimal item for tests.
+216    ///
+217    /// The struct has twenty-odd fields, almost none of which any given test
+218    /// cares about, and repeating the literal per test is how a new field ends
+219    /// up added in thirty places. Set what matters on the result.
+220    ///
+221    /// TRACES: UR-081 | DR-243
+222    #[cfg(any(test, feature = "conformance"))]
+223    pub fn sample(id: &str, url: &str) -> Self {
+224        Self {
+225            transport: None,
+226            id: id.to_string(),
+227            title: id.to_string(),
+228            name: None,
+229            artist: None,
+230            album: None,
+231            album_name: None,
+232            album_id: None,
+233            artist_items: None,
+234            artists: None,
+235            primary_image_tag: None,
+236            image_id: None,
+237            item_type: None,
+238            playlist_id: None,
+239            duration: None,
+240            artwork_url: None,
+241            media_type: MediaType::Video,
+242            source: MediaSource::DirectUrl {
+243                url: url.to_string(),
+244            },
+245            video_codec: None,
+246            needs_transcoding: false,
+247            video_width: None,
+248            video_height: None,
+249            subtitles: vec![],
+250            series_id: None,
+251            server_id: None,
+252        }
+253    }
+254}
+255
+256#[cfg(test)]
+257mod tests {
+258    use super::*;
+259    use std::path::PathBuf;
+260
+261    #[test]
+262    fn test_queue_context_album() {
+263        let context = QueueContext::Album {
+264            album_id: "album-123".to_string(),
+265            album_name: "Test Album".to_string(),
+266        };
+267
+268        assert!(matches!(context, QueueContext::Album { .. }));
+269    }
+270
+271    #[test]
+272    fn test_queue_context_playlist() {
+273        let context = QueueContext::Playlist {
+274            playlist_id: "playlist-456".to_string(),
+275            playlist_name: "Test Playlist".to_string(),
+276        };
+277
+278        assert!(matches!(context, QueueContext::Playlist { .. }));
+279    }
+280
+281    #[test]
+282    fn test_queue_context_custom() {
+283        let context = QueueContext::Custom;
+284        assert!(matches!(context, QueueContext::Custom));
+285    }
+286
+287    #[test]
+288    fn test_queue_context_serialization() {
+289        let context = QueueContext::Album {
+290            album_id: "alb-001".to_string(),
+291            album_name: "Album 001".to_string(),
+292        };
+293
+294        let json = serde_json::to_string(&context);
+295        assert!(json.is_ok());
+296        let serialized = json.unwrap();
+297        assert!(serialized.contains("album"));
+298    }
+299
+300    #[test]
+301    fn test_queue_context_default() {
+302        let context = QueueContext::default();
+303        assert!(matches!(context, QueueContext::Custom));
+304    }
+305
+306    #[test]
+307    fn test_queue_context_clone() {
+308        let context = QueueContext::Album {
+309            album_id: "clone-alb".to_string(),
+310            album_name: "Clone Album".to_string(),
+311        };
+312
+313        let cloned = context.clone();
+314        assert_eq!(context, cloned);
+315    }
+316
+317    #[test]
+318    fn test_subtitle_track_creation() {
+319        let track = SubtitleTrack {
+320            index: 0,
+321            url: "https://example.com/subs.vtt".to_string(),
+322            language: Some("eng".to_string()),
+323            label: Some("English".to_string()),
+324            mime_type: "text/vtt".to_string(),
+325        };
+326
+327        assert_eq!(track.index, 0);
+328        assert_eq!(track.language, Some("eng".to_string()));
+329    }
+330
+331    #[test]
+332    fn test_subtitle_track_without_language() {
+333        let track = SubtitleTrack {
+334            index: 1,
+335            url: "https://example.com/subs.srt".to_string(),
+336            language: None,
+337            label: Some("Subtitles".to_string()),
+338            mime_type: "application/x-subrip".to_string(),
+339        };
+340
+341        assert!(track.language.is_none());
+342        assert!(track.label.is_some());
+343    }
+344
+345    #[test]
+346    fn test_subtitle_track_serialization() {
+347        let track = SubtitleTrack {
+348            index: 0,
+349            url: "url.vtt".to_string(),
+350            language: Some("eng".to_string()),
+351            label: None,
+352            mime_type: "text/vtt".to_string(),
+353        };
+354
+355        let json = serde_json::to_string(&track);
+356        assert!(json.is_ok());
+357        let serialized = json.unwrap();
+358        assert!(serialized.contains("0"));
+359        assert!(serialized.contains("eng"));
+360    }
+361
+362    #[test]
+363    fn test_media_type_audio() {
+364        let media_type = MediaType::Audio;
+365        let json = serde_json::to_string(&media_type).unwrap();
+366        assert!(json.contains("audio"));
+367    }
+368
+369    #[test]
+370    fn test_media_type_video() {
+371        let media_type = MediaType::Video;
+372        let json = serde_json::to_string(&media_type).unwrap();
+373        assert!(json.contains("video"));
+374    }
+375
+376    #[test]
+377    fn test_media_source_remote() {
+378        let source = MediaSource::Remote {
+379            stream_url: "https://server.com/video.mp4".to_string(),
+380            jellyfin_item_id: "item-123".to_string(),
+381        };
+382
+383        assert!(matches!(source, MediaSource::Remote { .. }));
+384    }
+385
+386    #[test]
+387    fn test_media_source_local() {
+388        let source = MediaSource::Local {
+389            file_path: PathBuf::from("/path/to/video.mp4"),
+390            jellyfin_item_id: Some("item-456".to_string()),
+391        };
+392
+393        assert!(matches!(source, MediaSource::Local { .. }));
+394    }
+395
+396    #[test]
+397    fn test_media_source_local_without_jellyfin_id() {
+398        let source = MediaSource::Local {
+399            file_path: PathBuf::from("/downloads/audio.mp3"),
+400            jellyfin_item_id: None,
+401        };
+402
+403        assert!(matches!(source, MediaSource::Local { .. }));
+404    }
+405
+406    #[test]
+407    fn test_media_source_direct_url() {
+408        let source = MediaSource::DirectUrl {
+409            url: "https://external.com/stream".to_string(),
+410        };
+411
+412        assert!(matches!(source, MediaSource::DirectUrl { .. }));
+413    }
+414
+415    #[test]
+416    fn test_media_source_serialization() {
+417        let source = MediaSource::DirectUrl {
+418            url: "https://example.com/stream".to_string(),
+419        };
+420
+421        let json = serde_json::to_string(&source);
+422        assert!(json.is_ok());
+423        let serialized = json.unwrap();
+424        assert!(serialized.contains("directurl"));
+425    }
+426
+427    #[test]
+428    fn test_media_item_creation_minimal() {
+429        let item = MediaItem {
+430            // Audio and direct-URL items never negotiate a transport.
+431            transport: None,
+432            id: "item-1".to_string(),
+433            title: "Test Item".to_string(),
+434            name: None,
+435            artist: None,
+436            album: None,
+437            album_name: None,
+438            album_id: None,
+439            artist_items: None,
+440            artists: None,
+441            primary_image_tag: None,
+442            image_id: None,
+443            item_type: None,
+444            playlist_id: None,
+445            duration: None,
+446            artwork_url: None,
+447            media_type: MediaType::Video,
+448            source: MediaSource::DirectUrl {
+449                url: "https://example.com/video".to_string(),
+450            },
+451            video_codec: None,
+452            needs_transcoding: false,
+453            video_width: None,
+454            video_height: None,
+455            subtitles: vec![],
+456            series_id: None,
+457            server_id: None,
+458        };
+459
+460        assert_eq!(item.id, "item-1");
+461        assert_eq!(item.title, "Test Item");
+462        assert!(!item.needs_transcoding);
+463    }
+464
+465    #[test]
+466    fn test_media_item_jellyfin_id() {
+467        let item = MediaItem {
+468            // Audio and direct-URL items never negotiate a transport.
+469            transport: None,
+470            id: "item-2".to_string(),
+471            title: "Test".to_string(),
+472            name: None,
+473            artist: None,
+474            album: None,
+475            album_name: None,
+476            album_id: None,
+477            artist_items: None,
+478            artists: None,
+479            primary_image_tag: None,
+480            image_id: None,
+481            item_type: None,
+482            playlist_id: None,
+483            duration: None,
+484            artwork_url: None,
+485            media_type: MediaType::Audio,
+486            source: MediaSource::Remote {
+487                stream_url: "https://server/stream".to_string(),
+488                jellyfin_item_id: "jf-id-123".to_string(),
+489            },
+490            video_codec: None,
+491            needs_transcoding: false,
+492            video_width: None,
+493            video_height: None,
+494            subtitles: vec![],
+495            series_id: None,
+496            server_id: None,
+497        };
+498
+499        assert_eq!(item.jellyfin_id(), Some("jf-id-123"));
+500    }
+501
+502    #[test]
+503    fn test_media_item_jellyfin_id_local() {
+504        let item = MediaItem {
+505            // Audio and direct-URL items never negotiate a transport.
+506            transport: None,
+507            id: "item-3".to_string(),
+508            title: "Local".to_string(),
+509            name: None,
+510            artist: None,
+511            album: None,
+512            album_name: None,
+513            album_id: None,
+514            artist_items: None,
+515            artists: None,
+516            primary_image_tag: None,
+517            image_id: None,
+518            item_type: None,
+519            playlist_id: None,
+520            duration: None,
+521            artwork_url: None,
+522            media_type: MediaType::Video,
+523            source: MediaSource::Local {
+524                file_path: PathBuf::from("/local/video.mp4"),
+525                jellyfin_item_id: Some("jf-local".to_string()),
+526            },
+527            video_codec: None,
+528            needs_transcoding: false,
+529            video_width: None,
+530            video_height: None,
+531            subtitles: vec![],
+532            series_id: None,
+533            server_id: None,
+534        };
+535
+536        assert_eq!(item.jellyfin_id(), Some("jf-local"));
+537    }
+538
+539    #[test]
+540    fn test_media_item_jellyfin_id_direct_url() {
+541        let item = MediaItem {
+542            // Audio and direct-URL items never negotiate a transport.
+543            transport: None,
+544            id: "item-4".to_string(),
+545            title: "Direct".to_string(),
+546            name: None,
+547            artist: None,
+548            album: None,
+549            album_name: None,
+550            album_id: None,
+551            artist_items: None,
+552            artists: None,
+553            primary_image_tag: None,
+554            image_id: None,
+555            item_type: None,
+556            playlist_id: None,
+557            duration: None,
+558            artwork_url: None,
+559            media_type: MediaType::Video,
+560            source: MediaSource::DirectUrl {
+561                url: "https://external.com/media".to_string(),
+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
+572        assert_eq!(item.jellyfin_id(), None);
+573    }
+574
+575    #[test]
+576    fn test_media_item_with_subtitles() {
+577        let sub = SubtitleTrack {
+578            index: 0,
+579            url: "subs.vtt".to_string(),
+580            language: Some("eng".to_string()),
+581            label: Some("English".to_string()),
+582            mime_type: "text/vtt".to_string(),
+583        };
+584
+585        let item = MediaItem {
+586            // Audio and direct-URL items never negotiate a transport.
+587            transport: None,
+588            id: "item-subs".to_string(),
+589            title: "With Subs".to_string(),
+590            name: None,
+591            artist: None,
+592            album: None,
+593            album_name: None,
+594            album_id: None,
+595            artist_items: None,
+596            artists: None,
+597            primary_image_tag: None,
+598            image_id: None,
+599            item_type: None,
+600            playlist_id: None,
+601            duration: None,
+602            artwork_url: None,
+603            media_type: MediaType::Video,
+604            source: MediaSource::DirectUrl {
+605                url: "video.mp4".to_string(),
+606            },
+607            video_codec: None,
+608            needs_transcoding: false,
+609            video_width: None,
+610            video_height: None,
+611            subtitles: vec![sub],
+612            series_id: None,
+613            server_id: None,
+614        };
+615
+616        assert_eq!(item.subtitles.len(), 1);
+617        assert_eq!(item.subtitles[0].language, Some("eng".to_string()));
+618    }
+619
+620    #[test]
+621    fn test_media_item_serialization() {
+622        let item = MediaItem {
+623            // Audio and direct-URL items never negotiate a transport.
+624            transport: None,
+625            id: "serial-item".to_string(),
+626            title: "Serial Test".to_string(),
+627            name: Some("Name".to_string()),
+628            artist: None,
+629            album: None,
+630            album_name: None,
+631            album_id: None,
+632            artist_items: None,
+633            artists: None,
+634            primary_image_tag: None,
+635            image_id: None,
+636            item_type: Some("Movie".to_string()),
+637            playlist_id: None,
+638            duration: Some(120.0),
+639            artwork_url: None,
+640            media_type: MediaType::Video,
+641            source: MediaSource::DirectUrl {
+642                url: "https://example.com/movie.mp4".to_string(),
+643            },
+644            video_codec: Some("h264".to_string()),
+645            needs_transcoding: false,
+646            video_width: Some(1920),
+647            video_height: Some(1080),
+648            subtitles: vec![],
+649            series_id: None,
+650            server_id: None,
+651        };
+652
+653        let json = serde_json::to_string(&item);
+654        assert!(json.is_ok());
+655        let serialized = json.unwrap();
+656        assert!(serialized.contains("serial-item"));
+657        assert!(serialized.contains("Serial Test"));
+658    }
+659}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/media_player.rs.html b/api/src/jellytau_lib/player/media_player.rs.html new file mode 100644 index 00000000..c3df25ce --- /dev/null +++ b/api/src/jellytau_lib/player/media_player.rs.html @@ -0,0 +1,329 @@ +media_player.rs - sourceSkip to main content

jellytau_lib/player/
media_player.rs

1//! The `MediaPlayer` contract: one API, interchangeable engines.
+2//!
+3//! See docs/specs/media-player-controller.md.
+4//!
+5//! This replaces [`PlayerBackend`](super::backend::PlayerBackend), which
+6//! abstracts a *device* — `load`, then `seek` — rather than an *intent*. That
+7//! distinction is not academic; it produced four shipped defects in one day:
+8//!
+9//! * A start position was not expressible, so every caller sequenced
+10//!   `load()` + `seek()` itself and each raced the engine's asynchronous load
+11//!   independently. Resume worked through one caller and silently failed through
+12//!   another (DR-241).
+13//! * Whether a stream could be seeked in place was decided *above* the engines,
+14//!   by a truth table in a command handler, for engines it does not own (DR-238).
+15//! * Nothing in the contract obliged an engine to report its own state, so a
+16//!   handler for mpv's `pause` property sat unreachable and the play/pause
+17//!   control never moved (DR-239).
+18//!
+19//! The contract below is written so each of those is a compile-time or
+20//! conformance-time failure rather than a runtime surprise.
+21//!
+22//! TRACES: UR-081 | DR-242
+23
+24// Scaffolding: nothing consumes this contract until `PlayerController` is
+25// ported to it (DR-245). Kept out of `cfg(test)` deliberately — it is production
+26// code being built in shippable steps, not a test fixture. Remove this allow
+27// when the controller talks to `MediaPlayer`.
+28#![allow(dead_code)]
+29
+30use std::time::Duration;
+31
+32use super::backend::PlayerError;
+33use super::media::MediaItem;
+34use crate::repository::stream_selection::StreamSelection;
+35use crate::settings::AudioSettings;
+36
+37/// Seconds reported by an engine, as a `Duration`, without trusting the number.
+38///
+39/// `Duration::from_secs_f64` **panics** on a negative or non-finite value, and
+40/// no engine promises otherwise. ExoPlayer reports `C.TIME_UNSET` —
+41/// `Long::MIN_VALUE`, about -9.2e15 — for a stream whose length it does not
+42/// know, which is every background-audio handoff: `/Audio/{id}/universal` is a
+43/// chunked, length-less transcode.
+44///
+45/// Held as a float that junk was harmless. Converted to a `Duration` it became
+46/// a panic that killed the backend mid-handoff and left a black screen with no
+47/// controls. Every engine crossing into this contract goes through here.
+48///
+49/// TRACES: UR-005 | DR-252
+50pub fn duration_from_secs(seconds: f64) -> Option<Duration> {
+51    (seconds.is_finite() && seconds > 0.0).then(|| Duration::from_secs_f64(seconds))
+52}
+53
+54/// What an engine is doing right now.
+55///
+56/// `Opening` is the state the previous design could not express, and is the
+57/// direct cause of DR-241: a seek that arrived while the engine had nothing
+58/// loaded had no phase to be queued against, so it was simply discarded and
+59/// playback began at zero.
+60#[derive(Debug, Clone, PartialEq, Eq)]
+61pub enum Phase {
+62    /// Nothing loaded. `close()` must reach this, and must be silent here.
+63    Idle,
+64    /// An `open` is in flight. Position is not yet meaningful; a `seek` arriving
+65    /// now must be honoured once the engine reaches `Ready`, never dropped.
+66    Opening,
+67    /// Loaded and able to play, but not advancing.
+68    Ready,
+69    Playing,
+70    Paused,
+71    /// Reached the end of the item by itself. Distinct from `Idle`, because
+72    /// autoplay cares which one happened.
+73    Ended,
+74    Failed(String),
+75}
+76
+77impl Phase {
+78    /// Whether the engine currently holds an item.
+79    pub fn has_media(&self) -> bool {
+80        !matches!(self, Phase::Idle | Phase::Failed(_))
+81    }
+82
+83    /// Whether playback is advancing.
+84    pub fn is_active(&self) -> bool {
+85        matches!(self, Phase::Playing)
+86    }
+87}
+88
+89/// Everything the UI consumes, read as one coherent value.
+90///
+91/// Deliberately a single snapshot rather than a dozen getters: reading position
+92/// and duration through separate calls is how a paused player reported
+93/// `<position> / 0.0` when a file unloaded between them.
+94#[derive(Debug, Clone)]
+95pub struct PlaybackSnapshot {
+96    pub phase: Phase,
+97    pub position: Duration,
+98    /// `None` while unknown — a live stream, or an item still opening.
+99    pub duration: Option<Duration>,
+100    /// Whether `seek` can be expected to land. False for live edges.
+101    pub seekable: bool,
+102    /// 0.0 – 1.0.
+103    pub volume: f32,
+104    pub muted: bool,
+105    pub rate: f64,
+106    pub audio_track: Option<i32>,
+107    pub subtitle_track: Option<i32>,
+108}
+109
+110impl Default for PlaybackSnapshot {
+111    fn default() -> Self {
+112        Self {
+113            phase: Phase::Idle,
+114            position: Duration::ZERO,
+115            duration: None,
+116            seekable: false,
+117            volume: 1.0,
+118            muted: false,
+119            rate: 1.0,
+120            audio_track: None,
+121            subtitle_track: None,
+122        }
+123    }
+124}
+125
+126/// What an engine can do, so callers adapt without naming engines.
+127///
+128/// If a caller ever branches on *which* engine it holds, this struct is missing
+129/// something — add it here rather than sniffing. Engine identity leaking into
+130/// callers is the coupling DR-238 came from.
+131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+132pub struct Capabilities {
+133    /// The engine renders pictures, not only sound.
+134    pub video: bool,
+135    /// Audio settings (EQ, normalisation, gapless) are honoured.
+136    pub audio_settings: bool,
+137    /// Subtitle tracks can be selected without re-opening.
+138    pub subtitle_switching: bool,
+139    /// Audio tracks can be selected without re-opening.
+140    pub audio_track_switching: bool,
+141    /// A *server-side transcode* can be seeked without re-opening the stream.
+142    ///
+143    /// True for hls.js, which seeks within the VOD playlist it is handed and
+144    /// lets the server catch up. False for mpv, whose HLS demuxer cannot make
+145    /// the server transcode from a new offset.
+146    ///
+147    /// Declared by the engine rather than inferred by the caller. The previous
+148    /// design decided this from `is_hls` and `use_html5` in a command handler —
+149    /// on behalf of engines it did not own — which is how "who renders" came to
+150    /// mean "how do I seek" and why a transcoded seek silently did nothing the
+151    /// moment native video changed the renderer (DR-238).
+152    ///
+153    /// Re-negotiating a stream needs the repository, which sits above the
+154    /// engine, so the engine states the capability and the caller acts on it.
+155    pub seeks_transcoded_in_place: bool,
+156}
+157
+158impl Capabilities {
+159    /// mpv.
+160    ///
+161    /// Cannot seek a server-side transcode in place: its HLS demuxer will not
+162    /// make the server produce segments from a new offset, so the stream has to
+163    /// be re-opened.
+164    pub fn mpv() -> Self {
+165        Self {
+166            video: true,
+167            audio_settings: true,
+168            subtitle_switching: true,
+169            audio_track_switching: true,
+170            seeks_transcoded_in_place: false,
+171        }
+172    }
+173
+174    /// ExoPlayer.
+175    ///
+176    /// **Can** seek a transcode in place. It is a full HLS client, so like
+177    /// hls.js it seeks within the VOD playlist it was handed and lets the
+178    /// server catch up. Grouping it with mpv as "a native engine" gets this
+179    /// exactly backwards — being native is not the property that matters here,
+180    /// speaking HLS is, and that is the whole reason this is declared per
+181    /// engine rather than inferred from a category.
+182    pub fn exoplayer() -> Self {
+183        Self {
+184            video: true,
+185            audio_settings: true,
+186            subtitle_switching: true,
+187            audio_track_switching: true,
+188            seeks_transcoded_in_place: true,
+189        }
+190    }
+191
+192    /// An engine that renders through the webview element, where hls.js seeks
+193    /// within the playlist it was handed.
+194    pub fn webview() -> Self {
+195        Self {
+196            video: true,
+197            audio_settings: false,
+198            subtitle_switching: true,
+199            audio_track_switching: false,
+200            seeks_transcoded_in_place: true,
+201        }
+202    }
+203}
+204
+205/// A request to present an item.
+206///
+207/// `start` is the reason this type exists. Carrying it here — rather than
+208/// leaving callers to `seek` after `open` — is what closes the load/seek race,
+209/// because the engine is the only layer that knows when its pipeline can accept
+210/// a position.
+211#[derive(Debug, Clone)]
+212pub struct OpenRequest {
+213    pub media: MediaItem,
+214    pub selection: StreamSelection,
+215    /// Where to begin. `Duration::ZERO` means the start of the item.
+216    pub start: Duration,
+217    pub audio_track: Option<i32>,
+218    pub subtitle_track: Option<i32>,
+219    /// Begin playing as soon as the engine is able.
+220    pub autoplay: bool,
+221}
+222
+223impl OpenRequest {
+224    /// Open at the beginning, playing.
+225    pub fn new(media: MediaItem, selection: StreamSelection) -> Self {
+226        Self {
+227            media,
+228            selection,
+229            start: Duration::ZERO,
+230            audio_track: None,
+231            subtitle_track: None,
+232            autoplay: true,
+233        }
+234    }
+235
+236    pub fn starting_at(mut self, start: Duration) -> Self {
+237        self.start = start;
+238        self
+239    }
+240}
+241
+242/// Anything that can present media.
+243///
+244/// Implementations: `MpvPlayer` (Linux/Windows), `ExoPlayerPlayer` (Android),
+245/// `WebviewPlayer` (HTML5 element), and `FakePlayer` for tests. Every one of
+246/// them must pass [`super::conformance`].
+247pub trait MediaPlayer: Send {
+248    /// Present `req.selection`, beginning at `req.start`.
+249    ///
+250    /// One operation, deliberately. An engine that cannot start at an offset
+251    /// natively absorbs that internally — by deferring until loaded, or by
+252    /// re-opening — because it is the only layer that knows when it can.
+253    /// Callers must never follow `open` with a `seek` to achieve a start
+254    /// position; that is the bug this signature exists to prevent.
+255    fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError>;
+256
+257    fn play(&mut self) -> Result<(), PlayerError>;
+258    fn pause(&mut self) -> Result<(), PlayerError>;
+259
+260    /// Stop and release the current item.
+261    ///
+262    /// Must be **idempotent** and must leave the engine **silent**. "Stopped"
+263    /// and "producing no audio" were not the same thing in the previous design,
+264    /// and the gap between them is audible.
+265    fn close(&mut self) -> Result<(), PlayerError>;
+266
+267    /// Seek to an absolute position on the item's own timeline.
+268    ///
+269    /// Whether that is an in-place seek or a re-open of the stream is the
+270    /// engine's business: hls.js seeks within a VOD playlist, mpv's HLS demuxer
+271    /// cannot make a server transcode from a new offset. Callers state the
+272    /// destination and nothing else.
+273    fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
+274
+275    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
+276    fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError>;
+277    fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError>;
+278
+279    fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
+280    fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
+281
+282    /// One coherent read of the engine's state.
+283    fn snapshot(&self) -> PlaybackSnapshot;
+284
+285    fn capabilities(&self) -> Capabilities;
+286
+287    /// Apply EQ, normalisation and gapless settings.
+288    ///
+289    /// Provided rather than required: engines that cannot honour them say so
+290    /// through [`Capabilities::audio_settings`] and inherit this no-op, instead
+291    /// of every implementation carrying an `Ok(())` it does not mean.
+292    fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
+293        Ok(())
+294    }
+295
+296    fn audio_settings(&self) -> AudioSettings {
+297        AudioSettings::default()
+298    }
+299}
+300
+301#[cfg(test)]
+302mod tests {
+303    use super::*;
+304
+305    /// The value that killed the backend: `C.TIME_UNSET` as seconds.
+306    ///
+307    /// ExoPlayer reports it for any stream whose length it does not know, and
+308    /// `Duration::from_secs_f64` panics on it. A player must not be the place
+309    /// anyone discovers a float was strange.
+310    ///
+311    /// TRACES: UR-005 | DR-252 | UT-222
+312    #[test]
+313    fn test_junk_durations_do_not_panic() {
+314        // Long::MIN_VALUE milliseconds, as ExoPlayer hands it over.
+315        assert_eq!(duration_from_secs(-9_223_372_036_854_776.0), None);
+316        assert_eq!(duration_from_secs(-1.0), None);
+317        assert_eq!(duration_from_secs(0.0), None, "zero is not a duration");
+318        assert_eq!(duration_from_secs(f64::NAN), None);
+319        assert_eq!(duration_from_secs(f64::INFINITY), None);
+320        assert_eq!(duration_from_secs(f64::NEG_INFINITY), None);
+321
+322        // A real one still survives.
+323        assert_eq!(
+324            duration_from_secs(6997.024),
+325            Some(Duration::from_secs_f64(6997.024))
+326        );
+327    }
+328}
+
\ 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..593e275b --- /dev/null +++ b/api/src/jellytau_lib/player/mod.rs.html @@ -0,0 +1,4941 @@ +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;
+8#[cfg(any(test, feature = "conformance"))]
+9pub mod conformance;
+10pub mod events;
+11#[cfg(any(test, feature = "conformance"))]
+12pub mod fake_player;
+13#[cfg(test)]
+14mod fake_player_conformance;
+15pub mod legacy_player;
+16pub mod media;
+17pub mod media_player;
+18#[cfg(target_os = "linux")]
+19pub mod mpv_player;
+20pub mod queue;
+21pub mod seek;
+22pub mod session;
+23pub mod sleep_timer;
+24pub mod state;
+25pub mod stream_end;
+26
+27#[cfg(test)]
+28mod mpv_backend_test;
+29
+30// Platform-specific backends
+31#[cfg(target_os = "android")]
+32pub mod android;
+33
+34#[cfg(target_os = "linux")]
+35pub mod mpv_backend;
+36
+37/// Whether this process renders video natively — one answer, three consumers
+38/// (UR-080 / DR-231, DR-235).
+39pub mod native_video;
+40
+41/// mpv's render API into a framebuffer we own (UR-080 / DR-231, IR-033).
+42///
+43/// Deliberately *not* GTK-gated beyond the platform that currently builds it:
+44/// everything here is the portable half, and Windows reuses it unchanged behind
+45/// its own surface.
+46#[cfg(target_os = "linux")]
+47pub mod mpv_render;
+48
+49/// The native video surface mpv renders into (UR-080 / DR-231).
+50///
+51/// Linux-gated because the *surface* is GTK. Everything around it — the render
+52/// context, its lifetime, frame pacing, the device profile — is not.
+53#[cfg(target_os = "linux")]
+54pub mod video_surface;
+55
+56// Platforms with no native audio backend (e.g. Windows) render audio-only
+57// playback through a webview <audio> element, mirroring how all video renders.
+58#[cfg(not(any(target_os = "linux", target_os = "android")))]
+59pub mod webview_audio_backend;
+60
+61// Re-export commonly used types
+62use crate::repository::stream_selection::StreamSelection;
+63pub use autoplay::{AutoplayDecision, AutoplaySettings};
+64pub use backend::{NullBackend, PlayerBackend, PlayerError};
+65pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
+66pub use legacy_player::LegacyPlayer;
+67pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
+68pub use media_player::{MediaPlayer, OpenRequest, Phase};
+69pub use queue::{QueueManager, RepeatMode};
+70pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
+71pub use session::{MediaSessionManager, MediaSessionType};
+72pub use sleep_timer::{SleepTimerMode, SleepTimerState};
+73pub use state::{EndReason, PlayerState};
+74
+75// Re-export platform-specific backends
+76#[cfg(target_os = "android")]
+77pub use android::ExoPlayerBackend;
+78
+79#[cfg(target_os = "linux")]
+80pub use mpv_backend::MpvBackend;
+81
+82#[cfg(not(any(target_os = "linux", target_os = "android")))]
+83pub use webview_audio_backend::WebviewAudioBackend;
+84
+85#[cfg(target_os = "android")]
+86pub use android::{
+87    disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
+88    set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
+89};
+90
+91/// Where the player's playback reports go.
+92///
+93/// The controller's side of reporting is "send this, don't make me wait": a slow
+94/// or failing sync must never stall playback, so every send is fire-and-forget.
+95/// Production wires this to [`PlaybackReporter`] (local DB, server sync, offline
+96/// queueing); tests capture the operations instead of standing up a database and
+97/// an HTTP client, which is what let the missing reports below be written as
+98/// failing tests rather than found on a device.
+99///
+100/// TRACES: UR-025 | DR-179
+101pub trait PlaybackReportSink: Send + Sync {
+102    /// Deliver `operation`. Must not block the caller.
+103    fn send(&self, operation: PlaybackOperation);
+104}
+105
+106/// The production sink: hands each operation to the `PlaybackReporter`.
+107///
+108/// Reports originate on whatever thread playback ended or ticked on — including
+109/// JNI callbacks with no Tokio runtime attached — so the spawn falls back to a
+110/// throwaway runtime on its own thread rather than assuming one is current.
+111struct ReporterSink {
+112    reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
+113}
+114
+115impl PlaybackReportSink for ReporterSink {
+116    fn send(&self, operation: PlaybackOperation) {
+117        let reporter = self.reporter.clone();
+118        let task = async move {
+119            let guard = reporter.lock().await;
+120            let Some(reporter) = guard.as_ref() else {
+121                warn!("[PlayerController] PlaybackReporter not initialized; dropping report");
+122                return;
+123            };
+124            // `report` decides local-vs-server and queues for sync itself.
+125            if let Err(e) = reporter.report(operation, true).await {
+126                log::error!("[PlayerController] Failed to report playback: {}", e);
+127            }
+128        };
+129
+130        if let Ok(handle) = tokio::runtime::Handle::try_current() {
+131            handle.spawn(task);
+132        } else {
+133            std::thread::spawn(move || match tokio::runtime::Runtime::new() {
+134                Ok(rt) => rt.block_on(task),
+135                Err(e) => log::error!(
+136                    "[PlayerController] No runtime available to report playback: {}",
+137                    e
+138                ),
+139            });
+140        }
+141    }
+142}
+143
+144/// The position to report when a stream ends naturally.
+145///
+146/// The item's runtime when we know it, because the point of the report is to say
+147/// the episode *finished* and Jellyfin decides that by percentage — the last
+148/// position actually observed can be seconds short, and on a handoff whose ticks
+149/// stopped early it can be nowhere near the end. Without a runtime the best
+150/// available answer is where playback got to.
+151///
+152/// TRACES: UR-025, UR-040 | DR-179 | UT-179
+153fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
+154    match runtime {
+155        Some(runtime) if runtime > 0.0 => runtime,
+156        _ => last_position.max(0.0),
+157    }
+158}
+159
+160/// Seconds added per attempt before retrying a stream that failed with an error.
+161///
+162/// Attempt 1 waits this long, attempt 2 twice as long, and so on — a spread that
+163/// covers roughly a quarter-minute of outage across the retry budget without
+164/// leaving the user staring at a dead notification when the network is truly gone.
+165/// Only *read* by the Android error callback (`#[cfg(android)]`), but compiled
+166/// and unit-tested on the host, hence `allow(dead_code)` off-Android.
+167#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+168const RESUME_BACKOFF_STEP_SECS: u64 = 2;
+169
+170/// Metadata for the lockscreen / media notification.
+171///
+172/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
+173/// the local ExoPlayer is idle and so can't supply now-playing info. The session
+174/// poller fills this in from the remote Jellyfin session and pushes it to the
+175/// notification so the lockscreen stays in sync while casting.
+176///
+177/// TRACES: UR-006 | IR-006
+178#[derive(Debug, Clone)]
+179// Fields are read only by the Android MediaSession bridge; on other platforms
+180// `update_lockscreen_metadata` is a no-op, so they're constructed but unread.
+181#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+182pub struct LockscreenMetadata {
+183    pub title: String,
+184    pub artist: String,
+185    pub album: Option<String>,
+186    /// Track duration in milliseconds.
+187    pub duration_ms: i64,
+188    /// Current playback position in milliseconds.
+189    pub position_ms: i64,
+190    pub is_playing: bool,
+191}
+192
+193/// Push now-playing metadata to the Android lockscreen. No-op off Android, so the
+194/// session poller can call it unconditionally and stay platform-agnostic.
+195///
+196/// No-op on Linux specifically because there is no MPRIS/D-Bus publisher — see
+197/// IR-005, which is still Planned.
+198///
+199/// TRACES: UR-006 | IR-006
+200pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
+201    #[cfg(target_os = "android")]
+202    {
+203        return android::update_lockscreen_metadata(_meta);
+204    }
+205    #[cfg(not(target_os = "android"))]
+206    {
+207        Ok(())
+208    }
+209}
+210
+211/// Set the base offset (seconds) added to positions reported to the Android
+212/// lockscreen scrubber. Used by the background-audio handoff: the audio stream
+213/// starts at the handoff point (StartTimeTicks), so ExoPlayer's position is
+214/// relative and must be shifted back to absolute to match the full duration.
+215/// Pass 0.0 to clear on exit. No-op off Android.
+216pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
+217    #[cfg(target_os = "android")]
+218    {
+219        return android::set_position_offset(_offset_seconds);
+220    }
+221    #[cfg(not(target_os = "android"))]
+222    {
+223        Ok(())
+224    }
+225}
+226
+227use crate::utils::lock::MutexSafe;
+228use log::{debug, error, info, warn};
+229use std::sync::{Arc, Mutex};
+230use std::time::Duration;
+231use tokio::sync::Mutex as TokioMutex;
+232
+233use crate::jellyfin::JellyfinClient;
+234use crate::playback_reporting::{
+235    EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
+236};
+237use crate::repository::MediaRepository;
+238use crate::settings::AudioSettings;
+239use crate::utils::conversions::seconds_to_ticks;
+240
+241/// Central player controller that coordinates playback
+242pub struct PlayerController {
+243    /// The engine. One contract, so the controller stops branching on which
+244    /// platform it is running on — see docs/specs/media-player-controller.md.
+245    backend: Arc<Mutex<Box<dyn MediaPlayer>>>,
+246    queue: Arc<Mutex<QueueManager>>,
+247    jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
+248    muted: bool,
+249
+250    // Sleep timer state
+251    sleep_timer: Arc<Mutex<SleepTimerState>>,
+252
+253    // Autoplay settings
+254    autoplay_settings: Arc<Mutex<AutoplaySettings>>,
+255
+256    // Repository for fetching next episodes
+257    repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
+258
+259    // Event emitter for notifications
+260    event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
+261
+262    // Countdown cancellation handle
+263    countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>,
+264
+265    // Playback reporting (dual sync: local DB + server)
+266    playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
+267
+268    // Where playback reports go. Swappable so tests can assert on what the
+269    // player tells Jellyfin. See `PlaybackReportSink`.
+270    reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>,
+271
+272    // Bounds progress reports to one per item per 30s. Position ticks arrive
+273    // four times a second; the server needs a resume point, not a firehose.
+274    position_throttler: Arc<EventThrottler>,
+275
+276    // End reason tracking for autoplay decision making
+277    end_reason: Arc<Mutex<Option<EndReason>>>,
+278
+279    // Auto-play episode counter (session-based, resets on manual play)
+280    autoplay_episode_count: Arc<Mutex<u32>>,
+281
+282    // Base offset (seconds) of the active background-audio handoff.
+283    //
+284    // The audio-only stream is requested with `StartTimeTicks` = the position the
+285    // video was handed off at, so the server makes that point the stream's zero
+286    // and the native player reports position RELATIVE to it. Adding this base back
+287    // yields the absolute position to resume the video at on the way out.
+288    //
+289    // Lives on the controller (not beside the command) because the queue and this
+290    // offset describe the same stream: whenever the controller loads a different
+291    // one — notably the backend-driven advance to the next episode — the base has
+292    // to move with it.
+293    //
+294    // TRACES: UR-040 | DR-052
+295    background_audio_base: Arc<Mutex<f64>>,
+296
+297    // True while a background-audio handoff owns playback: the native audio
+298    // player is the real player and the webview <video> has been torn down.
+299    //
+300    // The teardown is what makes this necessary. It fires a DOM `pause` that the
+301    // frontend reports like any other, which would otherwise leave the controller
+302    // believing webview media is still active — aiming lockscreen transport at an
+303    // element that no longer exists (see `is_html5_active`).
+304    //
+305    // TRACES: UR-040 | DR-052, DR-097
+306    background_audio_active: Arc<Mutex<bool>>,
+307
+308    // Budget for re-opening a stream that ended short of the item's runtime.
+309    //
+310    // A resume re-requests the same URL, so a server that is genuinely gone would
+311    // otherwise end → resume → end without limit. The tracker only bounds retries
+312    // that make no progress; a resume that plays on refills it.
+313    //
+314    // TRACES: UR-040 | DR-129
+315    stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
+316
+317    // Last state reported by a webview-rendered HTML5 <video>/<audio> element.
+318    //
+319    // Webview-rendered media is played by an element the native backend cannot
+320    // reach, so the backend's own state() says nothing about it. Tracking the
+321    // REPORTED state here is what lets transport (play/pause/toggle) be decided
+322    // in Rust for that media instead of the frontend reading `el.paused` off the
+323    // DOM — a value that flips transiently while buffering/seeking and caused
+324    // competing intents to take opposing actions. `None` means no webview media
+325    // is active and the native backend is authoritative. See DR-097.
+326    html5_playing: Arc<Mutex<Option<bool>>>,
+327
+328    // Last position/duration reported by webview-rendered media.
+329    //
+330    // On the webview path the `<video>` element IS the player: nothing is loaded
+331    // into the native backend, so `backend.position()` is a permanent 0. Those
+332    // reports used to be re-emitted to the frontend and then dropped, which is
+333    // why every position the *backend* sent to Jellyfin — including the stop
+334    // report that sets the resume point — was zero, overwriting the correct one
+335    // the frontend had just sent. Storing them here makes
+336    // `absolute_position()` answer for both rendering paths.
+337    //
+338    // TRACES: UR-005, UR-025 | DR-178
+339    reported_time: Arc<Mutex<stream_end::ObservedTime>>,
+340}
+341
+342impl PlayerController {
+343    pub fn new(
+344        backend: Box<dyn MediaPlayer>,
+345        playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
+346        position_throttler: Arc<EventThrottler>,
+347    ) -> Self {
+348        let reports: Arc<dyn PlaybackReportSink> = Arc::new(ReporterSink {
+349            reporter: playback_reporter.clone(),
+350        });
+351        let controller = Self {
+352            backend: Arc::new(Mutex::new(backend)),
+353            queue: Arc::new(Mutex::new(QueueManager::new())),
+354            jellyfin_client: Arc::new(Mutex::new(None)),
+355            muted: false,
+356            sleep_timer: Arc::new(Mutex::new(SleepTimerState::default())),
+357            autoplay_settings: Arc::new(Mutex::new(AutoplaySettings::default())),
+358            repository: Arc::new(Mutex::new(None)),
+359            event_emitter: Arc::new(Mutex::new(None)),
+360            countdown_cancel: Arc::new(Mutex::new(None)),
+361            playback_reporter,
+362            reports: Arc::new(Mutex::new(reports)),
+363            position_throttler,
+364            end_reason: Arc::new(Mutex::new(None)),
+365            autoplay_episode_count: Arc::new(Mutex::new(0)),
+366            background_audio_base: Arc::new(Mutex::new(0.0)),
+367            background_audio_active: Arc::new(Mutex::new(false)),
+368            stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
+369            html5_playing: Arc::new(Mutex::new(None)),
+370            reported_time: Arc::new(Mutex::new(stream_end::ObservedTime::default())),
+371        };
+372
+373        // Start background timer thread for sleep timer countdown
+374        controller.start_timer_thread();
+375
+376        controller
+377    }
+378
+379    /// Configure the Jellyfin API client for automatic playback reporting
+380    pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
+381        let mut jellyfin = self.jellyfin_client.lock_safe();
+382        *jellyfin = client;
+383        log::info!(
+384            "[PlayerController] Jellyfin client configured: {}",
+385            jellyfin.is_some()
+386        );
+387    }
+388
+389    /// Get a reference to the Jellyfin client (for remote session control)
+390    pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> {
+391        self.jellyfin_client.clone()
+392    }
+393
+394    /// Configure the media repository used for next-episode lookups.
+395    ///
+396    /// The Android ExoPlayer ended-callback calls `on_playback_ended` with no
+397    /// repository handle (unlike the Linux HTML5 path, which passes one per
+398    /// call), so the controller needs a repository of its own or episode
+399    /// autoplay silently decides Stop.
+400    pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
+401        *self.repository.lock_safe() = Some(repo);
+402    }
+403
+404    /// Configure the playback reporter for dual sync (local DB + server).
+405    /// Called from `player_configure_jellyfin` on login/restore/reauth.
+406    pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
+407        let mut reporter_guard = self.playback_reporter.lock().await;
+408        *reporter_guard = reporter;
+409        log::info!(
+410            "[PlayerController] Playback reporter configured: {}",
+411            reporter_guard.is_some()
+412        );
+413    }
+414
+415    /// Get a reference to the playback reporter (for backend position updates)
+416    /// Will be used when position update hooks are added to backends
+417    #[allow(dead_code)]
+418    pub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>> {
+419        self.playback_reporter.clone()
+420    }
+421
+422    /// Get a reference to the position throttler (for backend position updates)
+423    /// Will be used when position update hooks are added to backends
+424    #[allow(dead_code)]
+425    pub fn position_throttler(&self) -> Arc<EventThrottler> {
+426        self.position_throttler.clone()
+427    }
+428
+429    /// Set the end reason for the next playback end event
+430    fn set_end_reason(&self, reason: EndReason) {
+431        log::debug!("[PlayerController] Setting end reason: {:?}", reason);
+432        *self.end_reason.lock_safe() = Some(reason);
+433    }
+434
+435    /// Get and clear the current end reason
+436    fn take_end_reason(&self) -> Option<EndReason> {
+437        self.end_reason.lock_safe().take()
+438    }
+439
+440    /// Read the end reason WITHOUT consuming it.
+441    ///
+442    /// `take_end_reason` has an owner: on Android the JNI ended-callback consumes
+443    /// the `NewTrackLoaded` every load sets, and the frontend's echoed call is the
+444    /// one that sees `None` and decides. The truncated-stream check runs in both
+445    /// calls and must not disturb that hand-off, so it peeks.
+446    fn peek_end_reason(&self) -> Option<EndReason> {
+447        *self.end_reason.lock_safe()
+448    }
+449
+450    /// Record that playback is being stopped by an expiring sleep timer.
+451    ///
+452    /// Stopping the backend makes it fire its ended callback (ExoPlayer does on
+453    /// Android), which lands in `on_playback_ended`. Without an end reason that
+454    /// reads as a natural finish and autoplay advances — defeating the timer.
+455    /// `UserStop` is the honest label: the stop was user-initiated, just via the
+456    /// timer they set rather than the stop button.
+457    ///
+458    /// Takes the shared slot rather than `&self` so the sleep-timer thread —
+459    /// which owns clones, not the controller — records it the same way.
+460    ///
+461    /// TRACES: UR-023, UR-026 | DR-029
+462    fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
+463        log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
+464        *end_reason.lock_safe() = Some(EndReason::UserStop);
+465    }
+466
+467    /// Increment autoplay episode counter. Returns true if limit is reached.
+468    fn increment_autoplay_count(&self) -> bool {
+469        let max = self.autoplay_settings.lock_safe().max_episodes;
+470
+471        if max == 0 {
+472            // Unlimited
+473            return false;
+474        }
+475
+476        let mut count = self.autoplay_episode_count.lock_safe();
+477        *count += 1;
+478        debug!(
+479            "[PlayerController] Autoplay episode count: {}/{}",
+480            *count, max
+481        );
+482
+483        *count >= max
+484    }
+485
+486    /// Reset autoplay episode counter (called on manual play actions)
+487    fn reset_autoplay_count(&self) {
+488        let mut count = self.autoplay_episode_count.lock_safe();
+489        if *count > 0 {
+490            debug!(
+491                "[PlayerController] Resetting autoplay episode counter (was {})",
+492                *count
+493            );
+494        }
+495        *count = 0;
+496    }
+497
+498    /// Load and play a single item (also sets the queue to contain only this item)
+499    pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
+500        debug!("[PlayerController] play_item: {}", item.title);
+501
+502        // Reset autoplay counter on manual play
+503        self.reset_autoplay_count();
+504
+505        // Update queue with this single item
+506        {
+507            let mut queue = self.queue.lock_safe();
+508            queue.set_queue(vec![item.clone()], 0);
+509        }
+510
+511        // Load and play the item
+512        self.load_and_play(&item)?;
+513
+514        Ok(())
+515    }
+516
+517    /// Set the current queue item without loading it into the playback backend.
+518    ///
+519    /// Used on platforms where video is rendered outside the native backend
+520    /// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
+521    /// item, but MPV must not start a redundant decode for it.
+522    ///
+523    /// Not gated to Linux. Its caller stopped being a `#[cfg]` branch and became
+524    /// a runtime question — "does this renderer draw the picture?" — so the
+525    /// `else` arm is compiled on every platform even where it never runs. The
+526    /// gate outliving its caller broke the Android build outright, which went
+527    /// unnoticed because nothing built for Android afterwards.
+528    pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
+529        debug!(
+530            "[PlayerController] set_current_item (no backend load): {}",
+531            item.title
+532        );
+533
+534        self.reset_autoplay_count();
+535        // A different item is current; the last one's reported position must not
+536        // be reported against it. This path is how webview-rendered video is
+537        // queued (no backend load at all), so it is exactly where a stale
+538        // reading would otherwise survive.
+539        // TRACES: UR-005 | DR-178
+540        self.clear_reported_time();
+541
+542        let mut queue = self.queue.lock_safe();
+543        queue.set_queue(vec![item], 0);
+544
+545        Ok(())
+546    }
+547
+548    /// Load and play an item without modifying the queue
+549    /// Use this when the queue is already set up and you just want to play a specific item from it
+550    pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
+551        debug!("[PlayerController] load_and_play: {}", item.title);
+552
+553        // Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track
+554        self.set_end_reason(EndReason::NewTrackLoaded);
+555
+556        // Loading into the native backend IS the statement that native renders
+557        // this item, so transport authority returns to it.
+558        //
+559        // `html5_playing` is written only by the webview element's own reports
+560        // and cleared only when it reports "stopped"/"idle". An element that
+561        // went away without that final report — or webview-rendered music
+562        // earlier in the same process — left `is_html5_active()` true, and then
+563        // every play/pause intent was emitted as a ControlCommand at an element
+564        // that no longer existed instead of reaching the backend. On Android's
+565        // native video path that is a pause button that does nothing, from the
+566        // surface tap and the control bar alike, while seek and skip keep
+567        // working because they decide elsewhere. Whether it happened at all
+568        // depended on what had played before, which is what made it look
+569        // intermittent.
+570        //
+571        // The webview re-establishes its own authority the moment an element
+572        // reports again, so nothing is lost on the HTML5 path: this is the same
+573        // "element is gone" semantics as the "stopped"/"idle" report, applied at
+574        // the point where we can know it directly.
+575        //
+576        // TRACES: UR-005, UR-003 | DR-193
+577        *self.html5_playing.lock_safe() = None;
+578
+579        let mut backend = self.backend.lock_safe();
+580        // One operation: the engine is handed the item and where to begin, so
+581        // there is no window between them for a position to be lost in.
+582        backend.open(OpenRequest::new(
+583            item.clone(),
+584            StreamSelection::for_queued_item(
+585                item.playable_url(),
+586                item.transport,
+587                item.needs_transcoding,
+588            ),
+589        ))?;
+590        drop(backend);
+591
+592        // A different item is loading; the last one's reported position must not
+593        // be attributed to it.
+594        self.clear_reported_time();
+595
+596        // Report playback start using PlaybackReporter (dual sync: local DB + server)
+597        if let Some(jellyfin_id) = item.jellyfin_id() {
+598            // Build playback context from item metadata
+599            let context = if item.album_id.is_some() {
+600                Some(PlaybackContext {
+601                    context_type: "container".to_string(),
+602                    context_id: item.album_id.clone(),
+603                })
+604            } else {
+605                None
+606            };
+607
+608            // Where this stream actually begins. Zero for an ordinary load, but a
+609            // background-audio handoff loads a stream whose zero is the handoff
+610            // point — telling the server the session started at 0:00 there both
+611            // misreports the session and, being a position, competes with the
+612            // real one.
+613            let position = self.absolute_position();
+614
+615            log::info!(
+616                "[PlayerController] Reporting playback start: {} @ {:.1}s",
+617                jellyfin_id,
+618                position
+619            );
+620            self.report(PlaybackOperation::Start {
+621                item_id: jellyfin_id.to_string(),
+622                position_ticks: seconds_to_ticks(position),
+623                context,
+624            });
+625        }
+626
+627        Ok(())
+628    }
+629
+630    /// Set the queue and start playing from the specified index
+631    pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
+632        self.play_queue_from(items, start_index, None)
+633    }
+634
+635    /// Set the queue and start playing from the specified index, optionally
+636    /// resuming the starting track at `start_position` (seconds).
+637    ///
+638    /// The seek happens immediately after load so the backend never audibly
+639    /// starts at 0 and there's no race against a fixed delay. Used when taking
+640    /// over playback from a remote session.
+641    pub fn play_queue_from(
+642        &self,
+643        items: Vec<MediaItem>,
+644        start_index: usize,
+645        start_position: Option<f64>,
+646    ) -> Result<(), PlayerError> {
+647        debug!(
+648            "[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
+649            items.len(),
+650            start_index,
+651            start_position
+652        );
+653
+654        // Reset autoplay counter on manual queue start
+655        self.reset_autoplay_count();
+656
+657        {
+658            let mut queue = self.queue.lock_safe();
+659            queue.set_queue(items, start_index);
+660        }
+661
+662        // Play the current item (without modifying the queue we just set)
+663        if let Some(item) = self.queue.lock_safe().current().cloned() {
+664            self.load_and_play(&item)?;
+665
+666            // Resume from the requested position. Seeking right after load (while
+667            // the backend lock is no longer held) avoids the start-at-0-then-jump
+668            // race that a delayed frontend seek suffers from.
+669            if let Some(position) = start_position {
+670                if position > 0.5 {
+671                    self.seek(position)?;
+672                }
+673            }
+674        }
+675
+676        Ok(())
+677    }
+678
+679    /// Replace the queue without starting local playback.
+680    ///
+681    /// Used when we're controlling a remote session: the tracks play on the
+682    /// remote device, but we keep the local queue in sync so the UI reflects
+683    /// what's playing and a later transfer-to-local has the queue to resume.
+684    pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
+685        debug!(
+686            "[PlayerController] set_queue (no local playback): {} items, index {}",
+687            items.len(),
+688            start_index
+689        );
+690        self.reset_autoplay_count();
+691        let mut queue = self.queue.lock_safe();
+692        queue.set_queue(items, start_index);
+693        Ok(())
+694    }
+695
+696    /// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
+697    /// player, so transport must be routed to it rather than the native backend.
+698    ///
+699    /// TRACES: UR-005 | DR-097
+700    pub fn is_html5_active(&self) -> bool {
+701        self.html5_playing.lock_safe().is_some()
+702    }
+703
+704    /// Whether the webview element last reported itself as playing. Meaningless
+705    /// unless [`Self::is_html5_active`] is true.
+706    ///
+707    /// TRACES: UR-005 | DR-097
+708    pub fn html5_is_playing(&self) -> bool {
+709        self.html5_playing.lock_safe().unwrap_or(false)
+710    }
+711
+712    /// Send a transport intent to the webview element that is rendering media.
+713    fn emit_html5_control(&self, action: &str) {
+714        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
+715            emitter.emit(PlayerStatusEvent::ControlCommand {
+716                action: action.to_string(),
+717                position: None,
+718            });
+719        }
+720    }
+721
+722    /// Play/resume playback
+723    pub fn play(&self) -> Result<(), PlayerError> {
+724        debug!("[PlayerController] play");
+725        // Webview-rendered media: the native backend isn't playing it, so drive
+726        // the element via a ControlCommand instead (DR-097).
+727        if self.is_html5_active() {
+728            self.emit_html5_control("play");
+729            return Ok(());
+730        }
+731        let mut backend = self.backend.lock_safe();
+732        backend.play()
+733    }
+734
+735    /// Pause playback
+736    pub fn pause(&self) -> Result<(), PlayerError> {
+737        if self.is_html5_active() {
+738            self.emit_html5_control("pause");
+739            return Ok(());
+740        }
+741        let mut backend = self.backend.lock_safe();
+742        backend.pause()
+743    }
+744
+745    /// Toggle play/pause.
+746    ///
+747    /// The decision is made HERE, from authoritative state — the reported webview
+748    /// state for HTML5-rendered media, or the native backend's state otherwise.
+749    /// The frontend must never decide this from the DOM (see DR-097).
+750    ///
+751    /// TRACES: UR-005 | DR-097
+752    pub fn toggle_playback(&self) -> Result<(), PlayerError> {
+753        if self.is_html5_active() {
+754            let action = if self.html5_is_playing() {
+755                "pause"
+756            } else {
+757                "play"
+758            };
+759            self.emit_html5_control(action);
+760            return Ok(());
+761        }
+762        let mut backend = self.backend.lock_safe();
+763        if backend.snapshot().phase.is_active() {
+764            backend.pause()
+765        } else {
+766            backend.play()
+767        }
+768    }
+769
+770    /// Stop playback
+771    pub fn stop(&self) -> Result<(), PlayerError> {
+772        // Set end reason to UserStop to prevent autoplay
+773        self.set_end_reason(EndReason::UserStop);
+774
+775        // Get current playback info before stopping
+776        let jellyfin_id = {
+777            let queue = self.queue.lock_safe();
+778            queue
+779                .current()
+780                .and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
+781        };
+782
+783        // Read across every rendering path BEFORE stopping: the backend zeroes
+784        // its position on stop, and the element that was reporting is gone.
+785        let position = self.absolute_position();
+786
+787        let mut backend = self.backend.lock_safe();
+788        backend.close()?;
+789        drop(backend);
+790        self.clear_reported_time();
+791
+792        // Stopping means *nothing is playing*, from any renderer — not "the
+793        // thing we currently believe owns playback has been asked to stop".
+794        //
+795        // A background-audio handoff swaps which renderer that is, and the swap
+796        // is bookkeeping that can be mid-flight: `exit_background_audio` marks
+797        // the webview element the player again the moment it is called, while
+798        // the element has not reloaded yet. A stop aimed at what the flags say
+799        // is playing therefore misses the audio stream that actually is, and it
+800        // resurfaces in the mini player as an audio track.
+801        //
+802        // Clearing the handoff here is the other half of that: a stop that
+803        // leaves the base offset and the active flag behind lets the next
+804        // position read be interpreted against a handoff that no longer exists.
+805        //
+806        // TRACES: UR-040, UR-005 | DR-250
+807        if self.is_background_audio_active() {
+808            debug!("[PlayerController] stop: clearing an active background-audio handoff");
+809        }
+810        *self.background_audio_active.lock_safe() = false;
+811        self.set_background_audio_base(0.0);
+812        *self.html5_playing.lock_safe() = None;
+813
+814        if let Some(jellyfin_id) = jellyfin_id {
+815            self.report_stopped_at(jellyfin_id, position);
+816        }
+817
+818        Ok(())
+819    }
+820
+821    /// Tell Jellyfin playback stopped at `position`, unless that position is
+822    /// zero.
+823    ///
+824    /// Jellyfin stores the reported position as the resume point, so a zero is
+825    /// not a harmless no-op — it is an instruction to forget where the viewer
+826    /// was. And it is never *information*: nobody watched zero seconds of
+827    /// anything, so every zero this app ever sent came from asking a player that
+828    /// was not rendering the media (webview video, or a handoff whose first tick
+829    /// had not landed). On a device trace, 14 of 14 stop reports in 35 minutes
+830    /// were zeroes, one of them 40s after the frontend had correctly reported
+831    /// 15:22 for the same episode.
+832    ///
+833    /// TRACES: UR-025, UR-005 | DR-179 | UT-178
+834    fn report_stopped_at(&self, jellyfin_id: String, position: f64) {
+835        if position <= 0.0 {
+836            debug!(
+837                "[PlayerController] Withholding zero-position stop report for {} \
+838                 (nothing played; reporting it would clear the resume point)",
+839                jellyfin_id
+840            );
+841            return;
+842        }
+843
+844        log::info!(
+845            "[PlayerController] Reporting playback stopped: {} @ {:.1}s",
+846            jellyfin_id,
+847            position
+848        );
+849        self.report(PlaybackOperation::Stopped {
+850            item_id: jellyfin_id,
+851            position_ticks: seconds_to_ticks(position),
+852        });
+853    }
+854
+855    /// Skip to next track
+856    ///
+857    /// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
+858    /// from triggering when the current track's EndFile event fires
+859    pub fn next(&self) -> Result<(), PlayerError> {
+860        // Reset autoplay counter on manual skip
+861        self.reset_autoplay_count();
+862
+863        let next_item = {
+864            let mut queue = self.queue.lock_safe();
+865            queue.next().cloned()
+866        };
+867
+868        debug!(
+869            "[PlayerController] next: {:?}",
+870            next_item.as_ref().map(|i| &i.title)
+871        );
+872
+873        if let Some(item) = next_item {
+874            self.load_and_play(&item)
+875        } else {
+876            debug!("[PlayerController] No next item, stopping");
+877            self.stop()
+878        }
+879    }
+880
+881    /// Skip to previous track
+882    ///
+883    /// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
+884    /// from triggering when the current track's EndFile event fires
+885    pub fn previous(&self) -> Result<(), PlayerError> {
+886        // Reset autoplay counter on manual skip
+887        self.reset_autoplay_count();
+888        // If we're more than 3 seconds in, restart current track
+889        {
+890            let backend = self.backend.lock_safe();
+891            if backend.snapshot().position.as_secs_f64() > 3.0 {
+892                debug!("[PlayerController] previous: restarting current track (position > 3s)");
+893                drop(backend);
+894                return self.seek(0.0);
+895            }
+896        }
+897
+898        let prev_item = {
+899            let mut queue = self.queue.lock_safe();
+900            queue.previous().cloned()
+901        };
+902
+903        debug!(
+904            "[PlayerController] previous: {:?}",
+905            prev_item.as_ref().map(|i| &i.title)
+906        );
+907
+908        if let Some(item) = prev_item {
+909            self.load_and_play(&item)
+910        } else {
+911            self.seek(0.0)
+912        }
+913    }
+914
+915    /// Seek to a position in seconds, **on the player's own timeline**.
+916    ///
+917    /// During a background-audio handoff that timeline is relative to the handoff
+918    /// point, so this is not the call a lockscreen scrub or a UI seek wants — use
+919    /// [`seek_absolute`](Self::seek_absolute), which speaks the episode's
+920    /// timeline and is what every caller outside the player itself means.
+921    pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
+922        let mut backend = self.backend.lock_safe();
+923        backend.seek(Duration::from_secs_f64(position.max(0.0)))
+924    }
+925
+926    /// Seek to an **absolute** position on the item's own timeline.
+927    ///
+928    /// This is the boundary every outside seek comes through — the UI, the
+929    /// lockscreen scrubber, a headset gesture — because all of them are looking
+930    /// at the whole episode, not at whatever fragment of it the player happens to
+931    /// be streaming.
+932    ///
+933    /// Outside a background-audio handoff the two timelines are the same and this
+934    /// is an ordinary seek. Inside one they differ by the handoff base, and the
+935    /// stream cannot be seeked at all: `/Audio/{id}/universal` is a chunked
+936    /// transcode with no length, so ExoPlayer either refuses or clamps — and a
+937    /// clamped seek lands at stream zero, which is the handoff point. That is the
+938    /// "jumps back to where I locked the screen" symptom. Honouring the seek means
+939    /// re-opening the URL at the new position, which is exactly what the
+940    /// truncation recovery already does, so it shares `resume_stream_at`.
+941    ///
+942    /// TRACES: UR-040, UR-005 | DR-159 | UT-155
+943    pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
+944        // Only a *streamed* handoff needs the rebuild. A downloaded file seeks
+945        // like any other file — and `resume_stream_at` refuses a non-remote
+946        // source, so sending one through here fails the seek outright.
+947        // TRACES: UR-071 | DR-180 | UT-181
+948        let rebuild = self.is_background_audio_active() && {
+949            let queue = self.queue.lock_safe();
+950            queue
+951                .current()
+952                .map(|item| {
+953                    Self::is_audio_only_video(item)
+954                        && matches!(item.source, MediaSource::Remote { .. })
+955                })
+956                .unwrap_or(false)
+957        };
+958
+959        if rebuild {
+960            return self.resume_stream_at(position.max(0.0)).await;
+961        }
+962
+963        self.seek(position).map_err(|e| e.to_string())
+964    }
+965
+966    /// Set volume (0.0 - 1.0)
+967    pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
+968        self.backend.lock_safe().set_volume(volume)
+969    }
+970
+971    /// Set the active audio track by stream index
+972    pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
+973        let mut backend = self.backend.lock_safe();
+974        backend.select_audio_track(Some(stream_index))
+975    }
+976
+977    /// Set the active subtitle track by stream index (None to disable subtitles)
+978    pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
+979        let mut backend = self.backend.lock_safe();
+980        backend.select_subtitle_track(stream_index)
+981    }
+982
+983    /// Get current state
+984    pub fn state(&self) -> PlayerState {
+985        let phase = self.backend.lock_safe().snapshot().phase;
+986        let media = self.queue.lock_safe().current().cloned();
+987        match (phase, media) {
+988            (Phase::Playing, Some(media)) => PlayerState::Playing {
+989                media,
+990                position: self.position(),
+991                duration: self.duration().unwrap_or(0.0),
+992            },
+993            (Phase::Paused, Some(media)) => PlayerState::Paused {
+994                media,
+995                position: self.position(),
+996                duration: self.duration().unwrap_or(0.0),
+997            },
+998            (Phase::Opening, Some(media)) => PlayerState::Loading { media },
+999            (Phase::Failed(error), media) => PlayerState::Error { media, error },
+1000            // Ready without an item, or anything terminal, reads as idle: the
+1001            // queue is what says whether there is something to resume.
+1002            _ => PlayerState::Idle,
+1003        }
+1004    }
+1005
+1006    /// What the engine currently rendering can do.
+1007    ///
+1008    /// TRACES: UR-081 | DR-246
+1009    pub fn capabilities(&self) -> crate::player::media_player::Capabilities {
+1010        self.backend.lock_safe().capabilities()
+1011    }
+1012
+1013    /// Get current position
+1014    pub fn position(&self) -> f64 {
+1015        self.backend.lock_safe().snapshot().position.as_secs_f64()
+1016    }
+1017
+1018    /// The position on the **item's own timeline**, whatever is rendering it.
+1019    ///
+1020    /// This is what every outbound position must be taken from — the resume point
+1021    /// sent to Jellyfin, the point the video reloads at when a handoff ends, the
+1022    /// truncation comparison. `position()` alone answers for exactly one of the
+1023    /// three ways this app plays media, and reads 0 for the other two:
+1024    ///
+1025    /// - **Webview `<video>`/`<audio>`**: nothing is loaded into the native
+1026    ///   backend, so its position is a permanent 0. The element's own reports are
+1027    ///   the only reading there is.
+1028    /// - **Background-audio handoff**: the audio-only stream's zero is the
+1029    ///   handoff point, and the base is added at the native tick boundary
+1030    ///   (DR-159) — so before the first tick lands, nothing has applied it.
+1031    ///   Flooring at the base is exact rather than approximate: the stream cannot
+1032    ///   physically be behind its own starting point.
+1033    /// - **Native playback**: the backend is authoritative and both other terms
+1034    ///   are zero, so the max is its own value.
+1035    ///
+1036    /// Returning to the foreground during that pre-first-tick window is what
+1037    /// restarted an episode from 0:00 and wiped its server-side resume point.
+1038    ///
+1039    /// TRACES: UR-040, UR-005, UR-025 | DR-178 | UT-176, UT-177
+1040    pub fn absolute_position(&self) -> f64 {
+1041        let native = self.backend.lock_safe().snapshot().position.as_secs_f64();
+1042        let reported = self.reported_time.lock_safe().last_position();
+1043        let base = if self.is_background_audio_active() {
+1044            *self.background_audio_base.lock_safe()
+1045        } else {
+1046            0.0
+1047        };
+1048
+1049        native.max(reported).max(base)
+1050    }
+1051
+1052    /// The duration last reported by webview-rendered media, if any.
+1053    ///
+1054    /// TRACES: UR-005 | DR-178 | UT-177
+1055    pub fn observed_duration(&self) -> Option<f64> {
+1056        self.reported_time.lock_safe().last_duration()
+1057    }
+1058
+1059    /// Forget what webview-rendered media reported.
+1060    ///
+1061    /// Called wherever that element stops being the player — it was torn down,
+1062    /// a handoff took over, or a different item is loading. A stale position
+1063    /// outliving its element would be reported against whatever plays next.
+1064    ///
+1065    /// TRACES: UR-005 | DR-178 | UT-177
+1066    fn clear_reported_time(&self) {
+1067        self.reported_time.lock_safe().reset();
+1068    }
+1069
+1070    /// Replace the sink playback reports go to. Tests capture; production wires
+1071    /// the `PlaybackReporter` at construction and never swaps it.
+1072    ///
+1073    /// TRACES: UR-025 | DR-179
+1074    #[cfg_attr(not(test), allow(dead_code))]
+1075    pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>) {
+1076        *self.reports.lock_safe() = sink;
+1077    }
+1078
+1079    /// Send a playback report. Fire-and-forget by contract, so callers can do
+1080    /// this while holding nothing and waiting for nothing.
+1081    ///
+1082    /// TRACES: UR-025 | DR-179
+1083    fn report(&self, operation: PlaybackOperation) {
+1084        let sink = self.reports.lock_safe().clone();
+1085        sink.send(operation);
+1086    }
+1087
+1088    /// The Jellyfin id of whatever is currently queued, if it has one.
+1089    fn current_jellyfin_id(&self) -> Option<String> {
+1090        let queue = self.queue.lock_safe();
+1091        queue
+1092            .current()
+1093            .and_then(|item| item.jellyfin_id().map(|id| id.to_string()))
+1094    }
+1095
+1096    /// Get duration.
+1097    ///
+1098    /// Falls back to what webview-rendered media reported for the same reason
+1099    /// [`absolute_position`](Self::absolute_position) does: on that path nothing
+1100    /// is loaded into the native backend, so its duration is `None` and the
+1101    /// element's report is the only one there is.
+1102    ///
+1103    /// TRACES: UR-005 | DR-178
+1104    pub fn duration(&self) -> Option<f64> {
+1105        // Zero is not a duration, it is an engine saying it does not know yet.
+1106        //
+1107        // ExoPlayer reports `C.TIME_UNSET` until it has resolved one, and
+1108        // `JellyTauPlayer.getDuration()` maps that to `0.0` — so the engine
+1109        // answers `Some(0.0)`, every "unknown duration" fallback below is
+1110        // skipped, and the seek bar is left with no scale. That presents as
+1111        // scrubbing being broken rather than as a duration that never arrived.
+1112        //
+1113        // The item usually knows: the catalog carried a runtime long before
+1114        // anything started decoding.
+1115        //
+1116        // TRACES: UR-005, UR-040 | DR-251
+1117        let usable = |d: f64| (d > 0.0).then_some(d);
+1118
+1119        self.backend
+1120            .lock_safe()
+1121            .snapshot()
+1122            .duration
+1123            .map(|d| d.as_secs_f64())
+1124            .and_then(usable)
+1125            .or_else(|| self.observed_duration().and_then(usable))
+1126            .or_else(|| {
+1127                self.queue
+1128                    .lock_safe()
+1129                    .current()
+1130                    .and_then(|item| item.duration)
+1131                    .and_then(usable)
+1132            })
+1133    }
+1134
+1135    /// Get queue reference
+1136    pub fn queue(&self) -> Arc<Mutex<QueueManager>> {
+1137        self.queue.clone()
+1138    }
+1139
+1140    /// True when the current item is a TV episode being played in audio-only
+1141    /// (background) mode — i.e. an `item_type == "Episode"` item loaded as
+1142    /// `MediaType::Audio`. Used to decide whether the backend must drive the
+1143    /// next-episode advance itself (the frontend is suspended in the background).
+1144    ///
+1145    /// Only *called* from the Android autoplay dispatch (`#[cfg(android)]`), but
+1146    /// compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.
+1147    #[cfg_attr(not(target_os = "android"), allow(dead_code))]
+1148    pub fn current_is_audio_episode(&self) -> bool {
+1149        self.queue
+1150            .lock_safe()
+1151            .current()
+1152            .map(|item| {
+1153                item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
+1154            })
+1155            .unwrap_or(false)
+1156    }
+1157
+1158    /// Clear the queue entirely (used when playback genuinely stops, e.g. the
+1159    /// sleep timer fires or the queue ends with repeat off). Pair with
+1160    /// `emit_queue_changed` so the frontend hides the mini player.
+1161    pub fn clear_queue(&self) {
+1162        self.queue.lock_safe().clear();
+1163    }
+1164
+1165    /// Toggle shuffle
+1166    pub fn toggle_shuffle(&self) {
+1167        self.queue.lock_safe().toggle_shuffle();
+1168    }
+1169
+1170    /// Cycle repeat mode
+1171    pub fn cycle_repeat(&self) {
+1172        self.queue.lock_safe().cycle_repeat();
+1173    }
+1174
+1175    /// Check if shuffle is enabled
+1176    pub fn is_shuffle(&self) -> bool {
+1177        self.queue.lock_safe().is_shuffle()
+1178    }
+1179
+1180    /// Get repeat mode
+1181    pub fn repeat_mode(&self) -> RepeatMode {
+1182        self.queue.lock_safe().repeat_mode()
+1183    }
+1184
+1185    /// Get current volume (0.0 - 1.0)
+1186    pub fn volume(&self) -> f32 {
+1187        self.backend.lock_safe().snapshot().volume
+1188    }
+1189
+1190    /// Check if muted
+1191    pub fn muted(&self) -> bool {
+1192        self.muted
+1193    }
+1194
+1195    /// Set audio settings (crossfade, gapless, normalization)
+1196    pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
+1197        self.backend.lock_safe().set_audio_settings(settings)
+1198    }
+1199
+1200    /// Get current audio settings
+1201    pub fn audio_settings(&self) -> AudioSettings {
+1202        self.backend.lock_safe().audio_settings()
+1203    }
+1204
+1205    // ===== Sleep Timer Methods =====
+1206
+1207    /// Set the event emitter for notifications
+1208    pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
+1209        let mut event_emitter = self.event_emitter.lock_safe();
+1210        *event_emitter = Some(emitter);
+1211    }
+1212
+1213    /// Get the event emitter
+1214    pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
+1215        self.event_emitter.lock_safe().clone()
+1216    }
+1217
+1218    /// Get sleep timer state
+1219    pub fn sleep_timer_state(&self) -> SleepTimerState {
+1220        self.sleep_timer.lock_safe().clone()
+1221    }
+1222
+1223    /// Set sleep timer mode (in-memory only, not persisted)
+1224    pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
+1225        let mut timer = self.sleep_timer.lock_safe();
+1226        timer.mode = mode.clone();
+1227        if let SleepTimerMode::Time { end_time } = mode {
+1228            let now = chrono::Utc::now().timestamp_millis();
+1229            timer.remaining_seconds = ((end_time - now) / 1000).max(0) as u32;
+1230        } else {
+1231            timer.remaining_seconds = 0;
+1232        }
+1233        drop(timer);
+1234
+1235        // Emit event to frontend for display update
+1236        self.emit_sleep_timer_changed();
+1237    }
+1238
+1239    /// Cancel sleep timer
+1240    pub fn cancel_sleep_timer(&self) {
+1241        self.set_sleep_timer(SleepTimerMode::Off);
+1242    }
+1243
+1244    /// Start background timer thread for sleep timer countdown updates
+1245    fn start_timer_thread(&self) {
+1246        let sleep_timer = self.sleep_timer.clone();
+1247        let event_emitter = self.event_emitter.clone();
+1248        let backend = self.backend.clone();
+1249        let end_reason = self.end_reason.clone();
+1250
+1251        std::thread::spawn(move || {
+1252            loop {
+1253                std::thread::sleep(Duration::from_secs(1));
+1254
+1255                let mut timer = sleep_timer.lock_safe();
+1256                if timer.is_active() {
+1257                    timer.update_remaining_seconds();
+1258
+1259                    // Time-based timer expired: stop playback
+1260                    if matches!(timer.mode, SleepTimerMode::Time { .. })
+1261                        && timer.remaining_seconds == 0
+1262                    {
+1263                        debug!("[SleepTimer] Time-based timer expired, stopping playback");
+1264                        timer.cancel();
+1265
+1266                        // Mark the stop *before* it reaches the backend. Stopping
+1267                        // makes the native player fire its ended callback, and
+1268                        // cancelling the timer above means on_playback_ended can no
+1269                        // longer tell this apart from a natural end — without this
+1270                        // it would show the next-episode popup / autoplay right
+1271                        // after the sleep timer fired.
+1272                        Self::note_sleep_timer_stop(&end_reason);
+1273
+1274                        // Emit cancelled state
+1275                        if let Some(emitter) = event_emitter.lock_safe().as_ref() {
+1276                            emitter.emit(PlayerStatusEvent::SleepTimerChanged {
+1277                                mode: SleepTimerMode::Off,
+1278                                remaining_seconds: 0,
+1279                            });
+1280                            // Tell the frontend playback must stop: HTML5 video
+1281                            // (Linux) plays outside the backend, so stopping the
+1282                            // backend below doesn't reach it.
+1283                            emitter.emit(PlayerStatusEvent::SleepTimerExpired);
+1284                        }
+1285                        drop(timer);
+1286
+1287                        // Stop the backend
+1288                        if let Err(e) = backend.lock_safe().close() {
+1289                            error!("[SleepTimer] Failed to stop playback: {}", e);
+1290                        }
+1291                        continue;
+1292                    }
+1293
+1294                    // Emit update event
+1295                    if let Some(emitter) = event_emitter.lock_safe().as_ref() {
+1296                        emitter.emit(PlayerStatusEvent::SleepTimerChanged {
+1297                            mode: timer.mode.clone(),
+1298                            remaining_seconds: timer.remaining_seconds,
+1299                        });
+1300                    }
+1301                }
+1302                drop(timer);
+1303            }
+1304        });
+1305    }
+1306
+1307    /// Emit sleep timer changed event to frontend
+1308    fn emit_sleep_timer_changed(&self) {
+1309        let timer = self.sleep_timer.lock_safe().clone();
+1310
+1311        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
+1312            emitter.emit(PlayerStatusEvent::SleepTimerChanged {
+1313                mode: timer.mode,
+1314                remaining_seconds: timer.remaining_seconds,
+1315            });
+1316        }
+1317    }
+1318
+1319    /// Emit queue changed event to frontend
+1320    pub fn emit_queue_changed(&self) {
+1321        let queue = self.queue.lock_safe();
+1322
+1323        debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
+1324            queue.items().len(), queue.current_index());
+1325
+1326        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
+1327            emitter.emit(PlayerStatusEvent::QueueChanged {
+1328                items: queue.items().to_vec(),
+1329                current_index: queue.current_index(),
+1330                shuffle: queue.is_shuffle(),
+1331                repeat: queue.repeat_mode(),
+1332                has_next: queue.has_next(),
+1333                has_previous: queue.has_previous(),
+1334            });
+1335        } else {
+1336            warn!("PlayerController::emit_queue_changed() - WARNING: No event emitter set!");
+1337        }
+1338    }
+1339
+1340    // ===== HTML5 video report methods =====
+1341    //
+1342    // On platforms where video is rendered in the webview (Linux WebKitGTK
+1343    // HTML5 <video>), the real player lives outside the native backend, so it
+1344    // cannot emit PlayerStatusEvents itself. The frontend HTML5 adapter reports
+1345    // DOM events here, and these methods re-emit them through the SAME event
+1346    // pipeline the native backends use. This keeps the frontend's player store
+1347    // fed from one place (playerEvents.ts) in both native and HTML5 modes, so
+1348    // the Rust controller stays the single source of truth for player state.
+1349
+1350    /// Report an HTML5 <video> state change (playing/paused/loading/stopped).
+1351    ///
+1352    /// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
+1353    /// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
+1354    pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
+1355        // A background-audio handoff has already moved playback to the native
+1356        // player and torn the element down; anything it still reports describes
+1357        // a video that is no longer playing. Dropping it keeps the UI on the
+1358        // audio that IS playing and leaves transport with the native backend.
+1359        if self.is_background_audio_active() {
+1360            debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
+1361            return;
+1362        }
+1363        // Track it: this is the authoritative play/pause state for
+1364        // webview-rendered media, and what transport decisions read (DR-097).
+1365        // "stopped"/"idle" mean the element is gone, so hand authority back to
+1366        // the native backend — otherwise music playback would keep emitting
+1367        // ControlCommands at a element that no longer exists.
+1368        let element_gone = {
+1369            let mut tracked = self.html5_playing.lock_safe();
+1370            *tracked = match state.as_str() {
+1371                "playing" => Some(true),
+1372                // "loading" counts as active-but-not-playing so a toggle during
+1373                // load resolves to "play" rather than falling through to the
+1374                // native backend.
+1375                "paused" | "loading" => Some(false),
+1376                // "stopped"/"idle": element is gone, native backend resumes authority.
+1377                _ => None,
+1378            };
+1379            tracked.is_none()
+1380        };
+1381        // Its last position goes with it: whatever plays next is loaded into the
+1382        // native backend, and a stale reading would be reported against that.
+1383        if element_gone {
+1384            self.clear_reported_time();
+1385        }
+1386        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
+1387            emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
+1388        }
+1389    }
+1390
+1391    /// Report an HTML5 <video> position tick.
+1392    ///
+1393    /// Re-emits a `PositionUpdate` event mirroring the native backends' periodic
+1394    /// position updates (the adapter is expected to throttle to ~250ms like MPV).
+1395    pub fn report_html5_position(&self, position: f64, duration: f64) {
+1396        // Stale by definition during a handoff — the native player's ticks are
+1397        // the real position. See `report_html5_state`.
+1398        if self.is_background_audio_active() {
+1399            return;
+1400        }
+1401        // The element is the player on this path, so this tick is the position —
+1402        // for the resume point, the stop report and everything else that asks.
+1403        self.reported_time.lock_safe().record(position, duration);
+1404
+1405        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
+1406            emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
+1407        }
+1408
+1409        self.report_progress_throttled(position);
+1410    }
+1411
+1412    /// Send a throttled progress report to Jellyfin.
+1413    ///
+1414    /// Progress is what makes a position survive anything other than a clean
+1415    /// exit — a crash, a swipe-away, a battery death — and lets another device
+1416    /// resume mid-episode. Webview-rendered media reported none: the frontend
+1417    /// service writes progress to the local DB only, and Rust had no position
+1418    /// for it to report. A device trace covering 35 minutes of playback hit
+1419    /// `/Sessions/Playing/Progress` exactly zero times.
+1420    ///
+1421    /// The throttler is the one the controller already owned for this purpose
+1422    /// (30s per item), so ticks arriving four times a second cost one request
+1423    /// per half-minute.
+1424    ///
+1425    /// TRACES: UR-005, UR-025 | DR-179 | UT-180
+1426    fn report_progress_throttled(&self, position: f64) {
+1427        if position <= 0.0 {
+1428            return;
+1429        }
+1430        let Some(item_id) = self.current_jellyfin_id() else {
+1431            return;
+1432        };
+1433        if !self.position_throttler.should_report(&item_id) {
+1434            return;
+1435        }
+1436
+1437        self.report(PlaybackOperation::Progress {
+1438            item_id: item_id.clone(),
+1439            position_ticks: seconds_to_ticks(position),
+1440            // Ticks only arrive while the element is playing; a pause is carried
+1441            // by the state report, not by a position that stopped moving.
+1442            is_paused: false,
+1443        });
+1444        self.position_throttler.mark_reported(&item_id);
+1445    }
+1446
+1447    /// Report that the HTML5 <video> element finished loading and knows its
+1448    /// duration. Mirrors the native `MediaLoaded` event.
+1449    pub fn report_html5_media_loaded(&self, duration: f64) {
+1450        // See `report_html5_state` — the element is not the player right now.
+1451        if self.is_background_audio_active() {
+1452            return;
+1453        }
+1454        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
+1455            emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
+1456        }
+1457    }
+1458
+1459    // ===== Autoplay Methods =====
+1460
+1461    /// Get autoplay settings
+1462    pub fn autoplay_settings(&self) -> AutoplaySettings {
+1463        self.autoplay_settings.lock_safe().clone()
+1464    }
+1465
+1466    /// Set autoplay settings (in-memory only, persistence handled by command layer)
+1467    pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
+1468        let validated = settings.with_validated_countdown();
+1469        *self.autoplay_settings.lock_safe() = validated;
+1470    }
+1471
+1472    /// Cancel active autoplay countdown
+1473    pub fn cancel_autoplay_countdown(&self) {
+1474        if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
+1475            *cancel_flag.lock_safe() = true;
+1476        }
+1477    }
+1478
+1479    /// Handle playback ended event - decides what to do next
+1480    ///
+1481    /// Only triggers autoplay if the track finished naturally (EndReason::Finished or None).
+1482    /// If EndReason is NewTrackLoaded, UserStop, UserSkip, or Error, returns Stop without autoplay.
+1483    pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
+1484        // A truncated stream is not an end at all, so this is decided BEFORE the
+1485        // end-reason gate below — which returns early for the `NewTrackLoaded`
+1486        // that every load sets, and would therefore swallow the whole question on
+1487        // Android's JNI callback: the one call guaranteed to run while the app is
+1488        // backgrounded and the webview cannot echo anything back.
+1489        if let Some(position) = self.truncated_stream_resume_position() {
+1490            return Ok(AutoplayDecision::ResumeStream { position });
+1491        }
+1492
+1493        // Check why playback ended
+1494        let end_reason = self.take_end_reason();
+1495
+1496        debug!(
+1497            "[PlayerController] on_playback_ended: end_reason={:?}",
+1498            end_reason
+1499        );
+1500
+1501        // Only proceed with autoplay logic if track finished naturally
+1502        match end_reason {
+1503            None | Some(EndReason::Finished) => {
+1504                // Track ended naturally, proceed with autoplay logic
+1505                debug!("[PlayerController] Track finished naturally, checking autoplay");
+1506            }
+1507            Some(EndReason::NewTrackLoaded) => {
+1508                // User loaded a new track, don't autoplay
+1509                debug!("[PlayerController] NewTrackLoaded - stopping without autoplay");
+1510                return Ok(AutoplayDecision::Stop);
+1511            }
+1512            Some(EndReason::UserStop) => {
+1513                // User stopped playback, don't autoplay
+1514                debug!("[PlayerController] UserStop - stopping without autoplay");
+1515                return Ok(AutoplayDecision::Stop);
+1516            }
+1517            Some(EndReason::UserSkip) => {
+1518                // User skipped, already handled by next/previous
+1519                debug!("[PlayerController] UserSkip - stopping without autoplay");
+1520                return Ok(AutoplayDecision::Stop);
+1521            }
+1522            Some(EndReason::Error) => {
+1523                // Playback error, don't autoplay
+1524                debug!("[PlayerController] Error - stopping without autoplay");
+1525                return Ok(AutoplayDecision::Stop);
+1526            }
+1527        }
+1528
+1529        let current_item = {
+1530            let queue = self.queue.lock_safe();
+1531            queue.current().cloned()
+1532        };
+1533
+1534        let Some(current) = current_item else {
+1535            return Ok(AutoplayDecision::Stop);
+1536        };
+1537
+1538        // The item is genuinely finished (a truncated stream returned above), so
+1539        // report it before anything advances — after an advance the queue's
+1540        // current item is the *next* episode and this one is unreachable.
+1541        self.report_completion(&current);
+1542
+1543        // Check sleep timer state
+1544        let timer_mode = {
+1545            let timer = self.sleep_timer.lock_safe();
+1546            timer.mode.clone()
+1547        };
+1548
+1549        match &timer_mode {
+1550            SleepTimerMode::Time { end_time } => {
+1551                // If time has expired, stop instead of playing next
+1552                let now = chrono::Utc::now().timestamp_millis();
+1553                if now >= *end_time {
+1554                    debug!("[PlayerController] Time-based sleep timer expired at track boundary");
+1555                    self.sleep_timer.lock_safe().cancel();
+1556                    self.emit_sleep_timer_changed();
+1557                    return Ok(AutoplayDecision::Stop);
+1558                }
+1559            }
+1560            SleepTimerMode::EndOfTrack => {
+1561                // Stop at end of track
+1562                self.sleep_timer.lock_safe().cancel();
+1563                self.emit_sleep_timer_changed();
+1564                return Ok(AutoplayDecision::Stop);
+1565            }
+1566            SleepTimerMode::Episodes { .. } => {
+1567                // Only count TV episodes (not audio tracks or movies). Note an
+1568                // episode played in background-audio mode is MediaType::Audio, so
+1569                // rely on is_episode_item (which checks item_type) rather than the
+1570                // media_type alone.
+1571                let is_episode = self.is_episode_item(&current).await;
+1572
+1573                if is_episode {
+1574                    let should_stop = self.sleep_timer.lock_safe().decrement_episode();
+1575                    self.emit_sleep_timer_changed();
+1576
+1577                    if should_stop {
+1578                        return Ok(AutoplayDecision::Stop);
+1579                    }
+1580                }
+1581            }
+1582            _ => {
+1583                // No action needed for other modes
+1584            }
+1585        }
+1586
+1587        // For episodes, fetch next episode and show popup.
+1588        // Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
+1589        // It's here for the Android ExoPlayer path where episode items sit in the
+1590        // backend queue — including background-audio mode, where the episode is a
+1591        // MediaType::Audio item, so gate on is_episode_item (item_type), not media_type.
+1592        if self.is_episode_item(&current).await {
+1593            let repo = self.repository.lock_safe().clone();
+1594            let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
+1595            let next_ep_result = if let Some(repo) = &repo {
+1596                // Degrade lookup failures to Stop: playback already ended, and
+1597                // surfacing an error here just kills autoplay silently upstream.
+1598                match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
+1599                    Ok(next) => next,
+1600                    Err(e) => {
+1601                        warn!(
+1602                            "[PlayerController] Next-episode lookup failed for {}: {}",
+1603                            jellyfin_id, e
+1604                        );
+1605                        None
+1606                    }
+1607                }
+1608            } else {
+1609                warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
+1610                None
+1611            };
+1612            if let Some(next_ep) = next_ep_result {
+1613                let settings = self.autoplay_settings.lock_safe().clone();
+1614
+1615                // Check if auto-play episode limit is reached
+1616                let limit_reached = self.increment_autoplay_count();
+1617                if limit_reached {
+1618                    debug!(
+1619                        "[PlayerController] Auto-play episode limit reached ({} episodes)",
+1620                        settings.max_episodes
+1621                    );
+1622                }
+1623
+1624                return Ok(AutoplayDecision::ShowNextEpisodePopup {
+1625                    current_episode: next_ep.0, // Repository MediaItem
+1626                    next_episode: next_ep.1,
+1627                    countdown_seconds: settings.countdown_seconds,
+1628                    auto_advance: settings.enabled && !limit_reached,
+1629                });
+1630            }
+1631            // No next episode found
+1632            return Ok(AutoplayDecision::Stop);
+1633        }
+1634
+1635        // For audio/movies, check if there's a next track in the queue
+1636        let has_next = {
+1637            let queue = self.queue.lock_safe();
+1638            queue.has_next()
+1639        };
+1640
+1641        if has_next {
+1642            // Advance to next track
+1643            Ok(AutoplayDecision::AdvanceToNext)
+1644        } else {
+1645            // End of queue
+1646            Ok(AutoplayDecision::Stop)
+1647        }
+1648    }
+1649
+1650    /// Report an item that just finished as stopped at its runtime, so Jellyfin
+1651    /// marks it played.
+1652    ///
+1653    /// Jellyfin decides "watched" from the `PlaybackStopped` report and its
+1654    /// position — no report, no completion, however much of the episode was
+1655    /// actually heard. In the foreground the frontend sends one when the
+1656    /// `<video>` ends. In background audio-only mode there is nobody: the webview
+1657    /// is suspended and its element was torn down at the handoff, while the
+1658    /// backend drove the advance to the next episode and said nothing about the
+1659    /// one that ended. An episode listened to end-to-end on the lockscreen
+1660    /// therefore never counted, and (before DR-179) was often reset to 0 by the
+1661    /// stop report that followed.
+1662    ///
+1663    /// Scoped to the audio-only handoff — the case the frontend provably cannot
+1664    /// report — so foreground playback keeps its single existing report rather
+1665    /// than gaining a second one. Music tracks ending natively remain
+1666    /// unreported; that is the same gap through a different door and wants its
+1667    /// own change.
+1668    ///
+1669    /// TRACES: UR-040, UR-025 | DR-179 | UT-179
+1670    fn report_completion(&self, item: &MediaItem) {
+1671        if !Self::is_audio_only_video(item) {
+1672            return;
+1673        }
+1674        let Some(jellyfin_id) = item.jellyfin_id().map(|id| id.to_string()) else {
+1675            return;
+1676        };
+1677
+1678        let position = completion_report_position(item.duration, self.absolute_position());
+1679        log::info!(
+1680            "[PlayerController] Audio-only {} finished — reporting complete at {:.1}s",
+1681            jellyfin_id,
+1682            position
+1683        );
+1684        self.report_stopped_at(jellyfin_id, position);
+1685    }
+1686
+1687    /// Record the base offset of a background-audio handoff (the position the
+1688    /// video was handed off at, which is the audio stream's zero).
+1689    ///
+1690    /// TRACES: UR-040 | DR-052
+1691    pub fn set_background_audio_base(&self, seconds: f64) {
+1692        *self.background_audio_base.lock_safe() = seconds.max(0.0);
+1693    }
+1694
+1695    /// Enter a background-audio handoff at `position` (the video's position, and
+1696    /// therefore the audio stream's zero).
+1697    ///
+1698    /// Hands transport authority to the native audio player: the webview
+1699    /// `<video>` is about to be torn down, so its last reports — including the
+1700    /// `pause` the teardown itself fires — must not keep it looking like the
+1701    /// player. Without this the lockscreen pause emitted a ControlCommand at a
+1702    /// dead element and the audio played straight through it.
+1703    ///
+1704    /// TRACES: UR-040, UR-005 | DR-052, DR-097
+1705    pub fn enter_background_audio(&self, position: f64) {
+1706        self.set_background_audio_base(position);
+1707        *self.background_audio_active.lock_safe() = true;
+1708        *self.html5_playing.lock_safe() = None;
+1709        // The element is being torn down; its last position describes a video
+1710        // that is no longer playing, and the base describes the one that is.
+1711        self.clear_reported_time();
+1712    }
+1713
+1714    /// Leave a background-audio handoff, returning the base offset to add to the
+1715    /// native player's relative position.
+1716    ///
+1717    /// The webview `<video>` becomes the player again once it reloads, so its
+1718    /// reports are honoured from here on.
+1719    ///
+1720    /// TRACES: UR-040, UR-005 | DR-052, DR-097
+1721    pub fn exit_background_audio(&self) -> f64 {
+1722        *self.background_audio_active.lock_safe() = false;
+1723        self.take_background_audio_base()
+1724    }
+1725
+1726    /// True while the native audio player owns playback via a background-audio
+1727    /// handoff.
+1728    ///
+1729    /// TRACES: UR-040 | DR-052
+1730    pub fn is_background_audio_active(&self) -> bool {
+1731        *self.background_audio_active.lock_safe()
+1732    }
+1733
+1734    /// Read and clear the background-audio base offset.
+1735    ///
+1736    /// TRACES: UR-040 | DR-052
+1737    pub fn take_background_audio_base(&self) -> f64 {
+1738        let mut base = self.background_audio_base.lock_safe();
+1739        std::mem::replace(&mut *base, 0.0)
+1740    }
+1741
+1742    /// Perform the auto-advance for a `ShowNextEpisodePopup` decision.
+1743    ///
+1744    /// Single place both end-of-playback dispatchers agree on: the Android JNI
+1745    /// callback (`nativeOnPlaybackEnded`) and the frontend-invoked command
+1746    /// (`player_on_playback_ended`). They used to each carry their own copy of
+1747    /// this branch, and the command's copy was missing the background-audio case
+1748    /// entirely — so an audio-only episode ending while backgrounded only ever
+1749    /// started a countdown that nothing could act on.
+1750    ///
+1751    /// TRACES: UR-040, UR-023 | DR-052
+1752    pub async fn auto_advance_to_next_episode(
+1753        &self,
+1754        next_episode: crate::repository::types::MediaItem,
+1755        countdown_seconds: u32,
+1756    ) {
+1757        // Background audio-only episode: the countdown only emits ticks — the
+1758        // advance itself is a `goto('/player/<id>')` in the webview, which cannot
+1759        // start audio while the app is backgrounded. Load the next episode's
+1760        // audio-only stream here instead, or playback stalls at the boundary.
+1761        if self.current_is_audio_episode() {
+1762            info!(
+1763                "[PlayerController] Background audio episode — advancing to {} in backend",
+1764                next_episode.id
+1765            );
+1766            match self
+1767                .advance_to_next_episode_audio_only(&next_episode.id)
+1768                .await
+1769            {
+1770                Ok(()) => self.emit_queue_changed(),
+1771                Err(e) => {
+1772                    error!(
+1773                        "[PlayerController] Background audio advance failed: {} — stopping",
+1774                        e
+1775                    );
+1776                    if let Some(emitter) = self.event_emitter() {
+1777                        emitter.emit(PlayerStatusEvent::PlaybackEnded);
+1778                    }
+1779                }
+1780            }
+1781            return;
+1782        }
+1783
+1784        // Foreground: the frontend drives the advance off the countdown ticks.
+1785        self.start_autoplay_countdown(next_episode, countdown_seconds);
+1786    }
+1787
+1788    /// A video item played through the native *audio* path — i.e. the background
+1789    /// audio-only handoff, the only place a length-less progressive transcode is
+1790    /// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
+1791    fn is_audio_only_video(item: &MediaItem) -> bool {
+1792        stream_end::is_audio_only_video(item)
+1793    }
+1794
+1795    /// Claim a resume attempt for the current stream, returning the absolute
+1796    /// position to re-open at and the 1-based attempt number. `None` when the
+1797    /// current item cannot meaningfully be re-requested, or when retrying at this
+1798    /// position has stopped helping.
+1799    ///
+1800    /// Only `Remote` sources qualify. A downloaded file cannot fail because of
+1801    /// the network, so re-opening one would paper over a real read error; a
+1802    /// `DirectUrl` is a plugin's endpoint with no Jellyfin item behind it.
+1803    ///
+1804    /// The player's position is relative to the stream's own zero (the handoff
+1805    /// URL's `StartTimeTicks`), so the base is added back to get an absolute one.
+1806    /// It is zero for everything else, where positions are already absolute.
+1807    ///
+1808    /// TRACES: UR-040, UR-004 | DR-129 | UT-117
+1809    fn claim_stream_resume(&self) -> Option<(f64, u32)> {
+1810        let current = {
+1811            let queue = self.queue.lock_safe();
+1812            queue.current().cloned()
+1813        }?;
+1814        if !matches!(current.source, MediaSource::Remote { .. }) {
+1815            return None;
+1816        }
+1817
+1818        // The Android position tick shifts by the handoff base before anything
+1819        // sees the value, so adding it again here would double-count it
+1820        // (DR-159) — `absolute_position` floors at the base instead, which is
+1821        // what a stream that died before its first tick needs. (DR-178)
+1822        let absolute = self.absolute_position();
+1823
+1824        match self.stream_resume.lock_safe().allow_attempt(absolute) {
+1825            Some(attempt) => Some((absolute, attempt)),
+1826            None => {
+1827                warn!(
+1828                    "[PlayerController] Stream for {} keeps failing at {:.1}s — giving up on resuming",
+1829                    current.id, absolute
+1830                );
+1831                None
+1832            }
+1833        }
+1834    }
+1835
+1836    /// The absolute position to re-open the current stream at, when the reported
+1837    /// end was really a dropped connection — `None` when the end looks genuine,
+1838    /// when this is not an audio-only handoff, or when retrying has stopped
+1839    /// helping.
+1840    ///
+1841    /// TRACES: UR-040 | DR-129 | UT-117
+1842    fn truncated_stream_resume_position(&self) -> Option<f64> {
+1843        // An explicit user intent already explains the end; never resume over it.
+1844        if matches!(
+1845            self.peek_end_reason(),
+1846            Some(EndReason::UserStop) | Some(EndReason::UserSkip) | Some(EndReason::Error)
+1847        ) {
+1848            return None;
+1849        }
+1850
+1851        // One lock at a time — `position()` reaches into the backend, and nesting
+1852        // that inside the queue lock would invent a lock order nothing else here
+1853        // takes.
+1854        let item_duration = {
+1855            let queue = self.queue.lock_safe();
+1856            let current = queue.current()?;
+1857            if !Self::is_audio_only_video(current) {
+1858                return None;
+1859            }
+1860            current.duration
+1861        };
+1862        // Already absolute — see claim_stream_resume. (DR-159)
+1863        let absolute = self.position().max(0.0);
+1864
+1865        // Only spend a resume attempt once the runtime says this really was cut
+1866        // short — a genuine end must stay a genuine end.
+1867        if !stream_end::is_truncated_end(
+1868            absolute,
+1869            item_duration,
+1870            stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
+1871        ) {
+1872            return None;
+1873        }
+1874
+1875        self.claim_stream_resume().map(|(position, _)| position)
+1876    }
+1877
+1878    /// Where to re-open the current stream after a *recoverable* playback error,
+1879    /// plus how many seconds to wait first.
+1880    ///
+1881    /// The media was decoding fine a moment ago, so a mid-playback failure on a
+1882    /// server stream is the network — and stopping the player (the previous
+1883    /// behaviour, via the frontend's error handler) turns a hiccup into "playback
+1884    /// just died". Applies to every streamed item, not only the audio-only
+1885    /// handoff: music and video reach here instead of the truncation path because
+1886    /// their streams declare a length, so a cut connection surfaces as an error
+1887    /// rather than a phantom end.
+1888    ///
+1889    /// The wait grows with the attempt number so a short outage has time to
+1890    /// clear, and the shared budget stops the retries when it doesn't.
+1891    ///
+1892    /// Called from the Android error callback, which decides in-process, and from
+1893    /// `player_recover_stream`, which is how the same decision reaches the
+1894    /// backends whose event thread has no controller to call — MPV is built
+1895    /// before the controller exists, so on Linux the error is emitted, echoed by
+1896    /// the frontend, and decided here.
+1897    ///
+1898    /// TRACES: UR-040, UR-004 | DR-129, DR-130 | UT-117
+1899    pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
+1900        self.claim_stream_resume()
+1901            .map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
+1902    }
+1903
+1904    /// Re-open the current stream at `position` after the network cut it short.
+1905    ///
+1906    /// Single place every dispatcher agrees on, for the same reason
+1907    /// `auto_advance_to_next_episode` is: the Android JNI callbacks and the
+1908    /// frontend-invoked command must not disagree about what a failed stream
+1909    /// means. None of them emits `PlaybackEnded` for this, so nothing downstream
+1910    /// clears the queue or tears the session down — from the outside this is a
+1911    /// buffering hiccup, which is what it actually was.
+1912    ///
+1913    /// Reloads the item **in place** rather than through `play_item`, which
+1914    /// replaces the queue with a single item: recovering a track that way would
+1915    /// throw away the rest of the album, turning a network blip into lost state.
+1916    ///
+1917    /// Two shapes of stream, two ways back to `position`:
+1918    ///
+1919    /// - The audio-only handoff's `/Audio/{id}/universal` transcode is chunked
+1920    ///   with no length, so it cannot be seeked. Its URL is rewritten to start at
+1921    ///   the position instead — edited, not rebuilt from the repository, since it
+1922    ///   already carries the user's audio track and media source and recovering
+1923    ///   from a network failure must not itself need a network round-trip.
+1924    /// - Everything else (a static file with byte ranges, an HLS playlist)
+1925    ///   declares its whole timeline, so re-preparing the URL it already has and
+1926    ///   seeking lands in the right place — and leaves any transcode session
+1927    ///   behind it alone.
+1928    ///
+1929    /// TRACES: UR-040, UR-004 | DR-129 | UT-117
+1930    pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
+1931        let current = {
+1932            let queue = self.queue.lock_safe();
+1933            queue.current().cloned()
+1934        }
+1935        .ok_or_else(|| "No current item to resume".to_string())?;
+1936
+1937        let MediaSource::Remote { stream_url, .. } = &current.source else {
+1938            return Err(format!(
+1939                "Cannot resume a non-remote source for {}",
+1940                current.id
+1941            ));
+1942        };
+1943
+1944        info!(
+1945            "[PlayerController] Stream for {} failed — re-opening at {:.1}s",
+1946            current.id, position
+1947        );
+1948
+1949        if !Self::is_audio_only_video(&current) {
+1950            self.load_and_play(&current).map_err(|e| e.to_string())?;
+1951            if position > 0.5 {
+1952                self.seek(position).map_err(|e| e.to_string())?;
+1953            }
+1954            return Ok(());
+1955        }
+1956
+1957        let restarted_url = stream_end::with_start_time(stream_url, position);
+1958        {
+1959            let queue_arc = self.queue.clone();
+1960            let mut queue = queue_arc.lock_safe();
+1961            if !queue.update_current_stream_url(restarted_url) {
+1962                return Err(format!("Failed to update stream URL for {}", current.id));
+1963            }
+1964        }
+1965        let resumed = {
+1966            let queue = self.queue.lock_safe();
+1967            queue.current().cloned()
+1968        }
+1969        .ok_or_else(|| "Current item vanished mid-resume".to_string())?;
+1970
+1971        // The re-opened stream's timeline starts at `position` (StartTimeTicks),
+1972        // so that is its zero: the exit-to-foreground maths and the lockscreen
+1973        // scrubber both read absolute positions off this base.
+1974        self.set_background_audio_base(position);
+1975        let _ = set_lockscreen_position_offset(position.max(0.0));
+1976
+1977        self.load_and_play(&resumed).map_err(|e| e.to_string())
+1978    }
+1979
+1980    /// Advance to the next episode while playing audio-only in the background.
+1981    ///
+1982    /// The normal autoplay-next path navigates the frontend to `/player/<id>`,
+1983    /// which is unavailable when the app is backgrounded and the WebView is
+1984    /// suspended. This drives the advance entirely in the backend: build the next
+1985    /// episode's *audio-only* stream URL and load it into the native audio player,
+1986    /// so playback continues without any frontend involvement (UR-040).
+1987    ///
+1988    /// `next_episode_id` is the Jellyfin item ID of the episode to play next.
+1989    ///
+1990    /// Reached through `auto_advance_to_next_episode`, which gates it on
+1991    /// `current_is_audio_episode()` — only ever true after a background-audio
+1992    /// handoff (Android), but compiled and unit-tested on every platform.
+1993    /// TRACES: UR-040, UR-023 | DR-052
+1994    pub async fn advance_to_next_episode_audio_only(
+1995        &self,
+1996        next_episode_id: &str,
+1997    ) -> Result<(), String> {
+1998        let repo = self
+1999            .repository
+2000            .lock_safe()
+2001            .clone()
+2002            .ok_or_else(|| "No repository for background episode advance".to_string())?;
+2003
+2004        // Details for session metadata (title/series/artwork) and the stream URL.
+2005        let next = repo
+2006            .get_item(next_episode_id)
+2007            .await
+2008            .map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
+2009
+2010        // Audio-only transcode from the start of the episode (no resume offset —
+2011        // a freshly-started next episode always plays from the beginning).
+2012        let stream_url = repo
+2013            .get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
+2014            .await
+2015            .map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
+2016
+2017        let media_item = MediaItem {
+2018            // Audio and direct-URL items never negotiate a transport.
+2019            transport: None,
+2020            id: next.id.clone(),
+2021            title: next.name.clone(),
+2022            name: Some(next.name.clone()),
+2023            artist: next.series_name.clone(),
+2024            album: None,
+2025            album_name: None,
+2026            album_id: None,
+2027            artist_items: None,
+2028            artists: None,
+2029            primary_image_tag: next.primary_image_tag.clone(),
+2030            image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
+2031            // Preserve episode identity so the NEXT end-of-track also advances.
+2032            item_type: Some("Episode".to_string()),
+2033            playlist_id: None,
+2034            duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
+2035            artwork_url: None,
+2036            media_type: MediaType::Audio,
+2037            source: MediaSource::Remote {
+2038                stream_url,
+2039                jellyfin_item_id: next.id.clone(),
+2040            },
+2041            video_codec: None,
+2042            needs_transcoding: false,
+2043            video_width: None,
+2044            video_height: None,
+2045            subtitles: vec![],
+2046            series_id: next.series_id.clone(),
+2047            server_id: Some(next.server_id.clone()),
+2048        };
+2049
+2050        // The previous episode's handoff base described the stream we are leaving.
+2051        // This one is built without StartTimeTicks, so its timeline is already
+2052        // absolute: clear the base (used to resolve the resume position on the way
+2053        // back to the foreground) and the lockscreen scrubber's matching shift.
+2054        self.set_background_audio_base(0.0);
+2055        let _ = set_lockscreen_position_offset(0.0);
+2056        // Different stream entirely: whatever was stuck about the last one is not
+2057        // this one's problem.
+2058        self.stream_resume.lock_safe().reset();
+2059
+2060        self.play_item(media_item).map_err(|e| e.to_string())
+2061    }
+2062
+2063    /// Handle video playback ended from HTML5 video element.
+2064    ///
+2065    /// HTML5 video plays independently of the Rust backend, so the backend
+2066    /// queue has no knowledge of the video item. This method bypasses the
+2067    /// queue lookup and end_reason check, using the provided Jellyfin item ID
+2068    /// to look up the item and check for next episodes.
+2069    pub async fn on_video_playback_ended(
+2070        &self,
+2071        item_id: &str,
+2072        repo: Arc<dyn crate::repository::MediaRepository>,
+2073    ) -> Result<AutoplayDecision, String> {
+2074        // Clear any stale end_reason (e.g., UserStop from stopping audio before video)
+2075        let stale_reason = self.take_end_reason();
+2076        if stale_reason.is_some() {
+2077            debug!(
+2078                "[PlayerController] Cleared stale end_reason for video: {:?}",
+2079                stale_reason
+2080            );
+2081        }
+2082
+2083        log::info!(
+2084            "[PlayerController] on_video_playback_ended: item_id={}",
+2085            item_id
+2086        );
+2087
+2088        // Check sleep timer state
+2089        let timer_mode = {
+2090            let timer = self.sleep_timer.lock_safe();
+2091            timer.mode.clone()
+2092        };
+2093
+2094        match &timer_mode {
+2095            SleepTimerMode::Time { end_time } => {
+2096                let now = chrono::Utc::now().timestamp_millis();
+2097                if now >= *end_time {
+2098                    debug!("[PlayerController] Time-based sleep timer expired at video end");
+2099                    self.sleep_timer.lock_safe().cancel();
+2100                    self.emit_sleep_timer_changed();
+2101                    return Ok(AutoplayDecision::Stop);
+2102                }
+2103            }
+2104            SleepTimerMode::EndOfTrack => {
+2105                self.sleep_timer.lock_safe().cancel();
+2106                self.emit_sleep_timer_changed();
+2107                return Ok(AutoplayDecision::Stop);
+2108            }
+2109            SleepTimerMode::Episodes { .. } => {
+2110                let should_stop = self.sleep_timer.lock_safe().decrement_episode();
+2111                self.emit_sleep_timer_changed();
+2112                if should_stop {
+2113                    return Ok(AutoplayDecision::Stop);
+2114                }
+2115            }
+2116            _ => {}
+2117        }
+2118
+2119        // Fetch next episode for the video that just ended. Degrade lookup
+2120        // failures to Stop: playback already ended, and propagating an error
+2121        // here just kills autoplay silently upstream.
+2122        let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
+2123            Ok(next) => next,
+2124            Err(e) => {
+2125                warn!(
+2126                    "[PlayerController] Next-episode lookup failed for {}: {}",
+2127                    item_id, e
+2128                );
+2129                None
+2130            }
+2131        };
+2132        if let Some(next_ep) = next_ep_result {
+2133            let settings = self.autoplay_settings.lock_safe().clone();
+2134
+2135            let limit_reached = self.increment_autoplay_count();
+2136            if limit_reached {
+2137                debug!(
+2138                    "[PlayerController] Auto-play episode limit reached ({} episodes)",
+2139                    settings.max_episodes
+2140                );
+2141            }
+2142
+2143            return Ok(AutoplayDecision::ShowNextEpisodePopup {
+2144                current_episode: next_ep.0,
+2145                next_episode: next_ep.1,
+2146                countdown_seconds: settings.countdown_seconds,
+2147                auto_advance: settings.enabled && !limit_reached,
+2148            });
+2149        }
+2150
+2151        // No next episode found
+2152        debug!("[PlayerController] No next episode found for {}", item_id);
+2153        Ok(AutoplayDecision::Stop)
+2154    }
+2155
+2156    /// Check if a media item is an episode (has Jellyfin ID to query).
+2157    ///
+2158    /// An explicit `item_type == "Episode"` wins so that a TV episode handed off
+2159    /// to the audio path for background playback (UR-040) is still recognised as
+2160    /// an episode — otherwise autoplay would fall through to the queue-based
+2161    /// audio path, find nothing next, and stop at the episode boundary. When the
+2162    /// type is unknown we fall back to the historical heuristic (video == episode).
+2163    async fn is_episode_item(&self, item: &MediaItem) -> bool {
+2164        match item.item_type.as_deref() {
+2165            Some("Episode") => true,
+2166            Some(_) => item.media_type == MediaType::Video,
+2167            None => item.media_type == MediaType::Video,
+2168        }
+2169    }
+2170
+2171    /// Fetch next episode for a series by looking up the season's episodes
+2172    /// sorted by index number and picking the one after the current episode.
+2173    ///
+2174    /// This is deterministic and doesn't depend on Jellyfin's "Next Up" API
+2175    /// (which relies on watch history that may not be updated yet due to
+2176    /// the async nature of playback progress reporting).
+2177    async fn fetch_next_episode_for_item(
+2178        &self,
+2179        item_id: &str,
+2180        repo: &Arc<dyn crate::repository::MediaRepository>,
+2181    ) -> Result<
+2182        Option<(
+2183            crate::repository::types::MediaItem,
+2184            crate::repository::types::MediaItem,
+2185        )>,
+2186        String,
+2187    > {
+2188        use crate::repository::types::GetItemsOptions;
+2189
+2190        // Get the current item details from repository
+2191        let current_repo_item = repo
+2192            .get_item(item_id)
+2193            .await
+2194            .map_err(|e| format!("Failed to get current item: {}", e))?;
+2195
+2196        // Need season_id to fetch sibling episodes
+2197        let season_id = match &current_repo_item.season_id {
+2198            Some(sid) => sid.clone(),
+2199            None => {
+2200                log::info!(
+2201                    "[PlayerController] Current item has no season_id, cannot find next episode"
+2202                );
+2203                return Ok(None);
+2204            }
+2205        };
+2206
+2207        // Fetch all episodes in the season sorted by episode number
+2208        let options = GetItemsOptions {
+2209            sort_by: Some("IndexNumber".to_string()),
+2210            sort_order: Some("Ascending".to_string()),
+2211            limit: Some(500),
+2212            include_item_types: Some(vec!["Episode".to_string()]),
+2213            ..Default::default()
+2214        };
+2215
+2216        let result = repo
+2217            .get_items(&season_id, Some(options))
+2218            .await
+2219            .map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
+2220
+2221        // Sort client-side by index_number to ensure correct ordering
+2222        // (offline repo ignores sort_by and sorts by sort_name instead)
+2223        let mut episodes = result.items;
+2224        episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
+2225        log::info!(
+2226            "[PlayerController] Season has {} episodes, looking for next after {}",
+2227            episodes.len(),
+2228            current_repo_item.id
+2229        );
+2230
+2231        // Find the current episode by ID and return the next one
+2232        if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
+2233            if current_idx + 1 < episodes.len() {
+2234                let next = &episodes[current_idx + 1];
+2235                log::info!(
+2236                    "[PlayerController] Found next episode: {} (index {})",
+2237                    next.name,
+2238                    current_idx + 1
+2239                );
+2240                return Ok(Some((current_repo_item, next.clone())));
+2241            } else {
+2242                log::info!("[PlayerController] Current episode is the last in the season");
+2243            }
+2244        } else {
+2245            log::info!(
+2246                "[PlayerController] Current episode not found in season episodes (ids: {:?})",
+2247                episodes
+2248                    .iter()
+2249                    .map(|e| e.id.as_str())
+2250                    .take(20)
+2251                    .collect::<Vec<_>>()
+2252            );
+2253        }
+2254
+2255        Ok(None)
+2256    }
+2257
+2258    /// Start autoplay countdown thread
+2259    pub fn start_autoplay_countdown(
+2260        &self,
+2261        _next_item: crate::repository::types::MediaItem,
+2262        countdown_seconds: u32,
+2263    ) {
+2264        // Create cancellation flag
+2265        let cancel_flag = Arc::new(Mutex::new(false));
+2266        *self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
+2267
+2268        let event_emitter = self.event_emitter.clone();
+2269
+2270        std::thread::spawn(move || {
+2271            let mut remaining = countdown_seconds;
+2272
+2273            while remaining > 0 {
+2274                std::thread::sleep(Duration::from_secs(1));
+2275
+2276                // Check cancellation
+2277                if *cancel_flag.lock_safe() {
+2278                    log::info!("[PlayerController] Autoplay countdown cancelled");
+2279                    return;
+2280                }
+2281
+2282                remaining -= 1;
+2283
+2284                // Emit countdown tick event
+2285                if let Some(emitter) = event_emitter.lock_safe().as_ref() {
+2286                    emitter.emit(PlayerStatusEvent::CountdownTick {
+2287                        remaining_seconds: remaining,
+2288                    });
+2289                }
+2290            }
+2291
+2292            // Countdown finished (final tick at 0 was already emitted inside the loop)
+2293            log::info!("[PlayerController] Autoplay countdown finished");
+2294        });
+2295    }
+2296}
+2297
+2298impl Default for PlayerController {
+2299    fn default() -> Self {
+2300        let playback_reporter = Arc::new(TokioMutex::new(None));
+2301        let position_throttler = Arc::new(EventThrottler::new());
+2302        Self::new(
+2303            Box::new(LegacyPlayer::new(
+2304                NullBackend::new(),
+2305                crate::player::media_player::Capabilities::mpv(),
+2306            )),
+2307            playback_reporter,
+2308            position_throttler,
+2309        )
+2310    }
+2311}
+2312
+2313#[cfg(test)]
+2314mod tests {
+2315
+2316    /// Stopping clears a background-audio handoff.
+2317    ///
+2318    /// This was verified by listening to a tablet, which is not a test. The
+2319    /// handoff swaps which renderer owns playback, and the swap is bookkeeping:
+2320    /// leaving the base offset and the active flag behind after a stop lets a
+2321    /// later position read be interpreted against a handoff that no longer
+2322    /// exists, and left the film playing on as an audio track in the mini
+2323    /// player.
+2324    ///
+2325    /// TRACES: UR-040, UR-005 | DR-250 | UT-224
+2326    #[test]
+2327    fn test_stop_clears_an_active_background_audio_handoff() {
+2328        let controller = PlayerController::default();
+2329        let item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
+2330        {
+2331            let queue_arc = controller.queue();
+2332            let mut queue = queue_arc.lock_safe();
+2333            queue.set_queue(vec![item], 0);
+2334        }
+2335
+2336        controller.enter_background_audio(557.5);
+2337        assert!(
+2338            controller.is_background_audio_active(),
+2339            "precondition: the handoff is active"
+2340        );
+2341
+2342        controller.stop().expect("stop failed");
+2343
+2344        assert!(
+2345            !controller.is_background_audio_active(),
+2346            "a stop must not leave a handoff behind for the next position read"
+2347        );
+2348        assert_eq!(
+2349            *controller.background_audio_base.lock_safe(),
+2350            0.0,
+2351            "the handoff base must be cleared with it"
+2352        );
+2353    }
+2354
+2355    /// A duration the engine does not know must fall back to the one the item
+2356    /// carries, and zero must count as "does not know".
+2357    ///
+2358    /// ExoPlayer reports `C.TIME_UNSET` for a duration it has not resolved;
+2359    /// `JellyTauPlayer.getDuration()` maps that to `0.0`, so the engine answers
+2360    /// `Some(0.0)` rather than `None` and every "unknown duration" fallback is
+2361    /// skipped. The seek bar then has no scale, which presents as scrubbing
+2362    /// being dead rather than as a missing duration.
+2363    ///
+2364    /// TRACES: UR-005, UR-040 | DR-251 | UT-221
+2365    #[test]
+2366    fn test_duration_falls_back_to_the_item_when_the_engine_does_not_know() {
+2367        let controller = PlayerController::default();
+2368        let mut item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
+2369        item.duration = Some(1800.0);
+2370
+2371        {
+2372            let queue_arc = controller.queue();
+2373            let mut queue = queue_arc.lock_safe();
+2374            queue.set_queue(vec![item], 0);
+2375        }
+2376
+2377        assert_eq!(
+2378            controller.duration(),
+2379            Some(1800.0),
+2380            "an engine that cannot report a duration should not erase the one the item carries"
+2381        );
+2382    }
+2383    use super::*;
+2384
+2385    /// Test emitter that captures events for asserting the HTML5 report methods
+2386    /// re-emit through the normal PlayerStatusEvent pipeline.
+2387    struct CapturingEmitter {
+2388        events: std::sync::Mutex<Vec<PlayerStatusEvent>>,
+2389    }
+2390
+2391    impl CapturingEmitter {
+2392        fn new() -> Self {
+2393            Self {
+2394                events: std::sync::Mutex::new(Vec::new()),
+2395            }
+2396        }
+2397        fn events(&self) -> Vec<PlayerStatusEvent> {
+2398            self.events.lock_safe().clone()
+2399        }
+2400    }
+2401
+2402    impl PlayerEventEmitter for CapturingEmitter {
+2403        fn emit(&self, event: PlayerStatusEvent) {
+2404            self.events.lock_safe().push(event);
+2405        }
+2406    }
+2407
+2408    /// Captures what the controller reports to Jellyfin, so tests can assert on
+2409    /// the operations themselves rather than on a database and an HTTP client.
+2410    struct CapturingReports {
+2411        operations: std::sync::Mutex<Vec<PlaybackOperation>>,
+2412    }
+2413
+2414    impl CapturingReports {
+2415        fn new() -> Self {
+2416            Self {
+2417                operations: std::sync::Mutex::new(Vec::new()),
+2418            }
+2419        }
+2420
+2421        /// Every `Stopped` report as `(item_id, position_seconds)`.
+2422        fn stops(&self) -> Vec<(String, f64)> {
+2423            self.operations
+2424                .lock_safe()
+2425                .iter()
+2426                .filter_map(|op| match op {
+2427                    PlaybackOperation::Stopped {
+2428                        item_id,
+2429                        position_ticks,
+2430                    } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
+2431                    _ => None,
+2432                })
+2433                .collect()
+2434        }
+2435
+2436        /// Every `Progress` report as `(item_id, position_seconds)`.
+2437        fn progress(&self) -> Vec<(String, f64)> {
+2438            self.operations
+2439                .lock_safe()
+2440                .iter()
+2441                .filter_map(|op| match op {
+2442                    PlaybackOperation::Progress {
+2443                        item_id,
+2444                        position_ticks,
+2445                        ..
+2446                    } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
+2447                    _ => None,
+2448                })
+2449                .collect()
+2450        }
+2451    }
+2452
+2453    impl PlaybackReportSink for CapturingReports {
+2454        fn send(&self, operation: PlaybackOperation) {
+2455            self.operations.lock_safe().push(operation);
+2456        }
+2457    }
+2458
+2459    #[test]
+2460    fn test_report_html5_state_emits_state_changed() {
+2461        let controller = PlayerController::default();
+2462        let emitter = Arc::new(CapturingEmitter::new());
+2463        controller.set_event_emitter(emitter.clone());
+2464
+2465        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
+2466
+2467        let events = emitter.events();
+2468        assert_eq!(events.len(), 1);
+2469        match &events[0] {
+2470            PlayerStatusEvent::StateChanged { state, media_id } => {
+2471                assert_eq!(state, "playing");
+2472                assert_eq!(media_id.as_deref(), Some("item-1"));
+2473            }
+2474            other => panic!("expected StateChanged, got {:?}", other),
+2475        }
+2476    }
+2477
+2478    #[test]
+2479    fn test_report_html5_position_emits_position_update() {
+2480        let controller = PlayerController::default();
+2481        let emitter = Arc::new(CapturingEmitter::new());
+2482        controller.set_event_emitter(emitter.clone());
+2483
+2484        controller.report_html5_position(12.5, 300.0);
+2485
+2486        let events = emitter.events();
+2487        assert_eq!(events.len(), 1);
+2488        match &events[0] {
+2489            PlayerStatusEvent::PositionUpdate { position, duration } => {
+2490                assert_eq!(*position, 12.5);
+2491                assert_eq!(*duration, 300.0);
+2492            }
+2493            other => panic!("expected PositionUpdate, got {:?}", other),
+2494        }
+2495    }
+2496
+2497    #[test]
+2498    fn test_report_html5_media_loaded_emits_media_loaded() {
+2499        let controller = PlayerController::default();
+2500        let emitter = Arc::new(CapturingEmitter::new());
+2501        controller.set_event_emitter(emitter.clone());
+2502
+2503        controller.report_html5_media_loaded(420.0);
+2504
+2505        let events = emitter.events();
+2506        assert_eq!(events.len(), 1);
+2507        match &events[0] {
+2508            PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
+2509            other => panic!("expected MediaLoaded, got {:?}", other),
+2510        }
+2511    }
+2512
+2513    // ===== HTML5 transport authority (DR-097) =====
+2514    //
+2515    // Webview-rendered video is played by an element the native backend cannot
+2516    // reach, so transport for it must be decided from the state the element
+2517    // REPORTS and executed by emitting a ControlCommand. Previously the frontend
+2518    // decided play-vs-pause itself by reading `el.paused` off the DOM, which
+2519    // flips transiently while buffering/seeking — two intents ~150ms apart read
+2520    // different values, took opposing actions, and self-sustained a pause loop.
+2521
+2522    #[test]
+2523    fn test_html5_state_is_tracked_from_reports() {
+2524        let controller = PlayerController::default();
+2525        let emitter = Arc::new(CapturingEmitter::new());
+2526        controller.set_event_emitter(emitter.clone());
+2527
+2528        // No HTML5 media reported yet: the native backend stays authoritative.
+2529        assert!(!controller.is_html5_active());
+2530
+2531        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
+2532        assert!(controller.is_html5_active());
+2533        assert!(controller.html5_is_playing());
+2534
+2535        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
+2536        assert!(controller.is_html5_active());
+2537        assert!(!controller.html5_is_playing());
+2538    }
+2539
+2540    #[test]
+2541    fn test_html5_toggle_from_paused_emits_play_control() {
+2542        let controller = PlayerController::default();
+2543        let emitter = Arc::new(CapturingEmitter::new());
+2544        controller.set_event_emitter(emitter.clone());
+2545        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
+2546
+2547        controller.toggle_playback().unwrap();
+2548
+2549        let controls: Vec<_> = emitter
+2550            .events()
+2551            .into_iter()
+2552            .filter_map(|e| match e {
+2553                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
+2554                _ => None,
+2555            })
+2556            .collect();
+2557        assert_eq!(controls, vec!["play".to_string()]);
+2558    }
+2559
+2560    #[test]
+2561    fn test_html5_toggle_from_playing_emits_pause_control() {
+2562        let controller = PlayerController::default();
+2563        let emitter = Arc::new(CapturingEmitter::new());
+2564        controller.set_event_emitter(emitter.clone());
+2565        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
+2566
+2567        controller.toggle_playback().unwrap();
+2568
+2569        let controls: Vec<_> = emitter
+2570            .events()
+2571            .into_iter()
+2572            .filter_map(|e| match e {
+2573                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
+2574                _ => None,
+2575            })
+2576            .collect();
+2577        assert_eq!(controls, vec!["pause".to_string()]);
+2578    }
+2579
+2580    #[test]
+2581    fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
+2582        // The loop signature: two intents in quick succession must NOT both
+2583        // resolve the same way, and must not produce opposing actions from a
+2584        // stale read. Rust's own tracked state makes the sequence deterministic
+2585        // as long as the element reports back between intents.
+2586        let controller = PlayerController::default();
+2587        let emitter = Arc::new(CapturingEmitter::new());
+2588        controller.set_event_emitter(emitter.clone());
+2589        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
+2590
+2591        controller.toggle_playback().unwrap();
+2592        // Element confirms the pause it was told to do.
+2593        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
+2594        controller.toggle_playback().unwrap();
+2595
+2596        let controls: Vec<_> = emitter
+2597            .events()
+2598            .into_iter()
+2599            .filter_map(|e| match e {
+2600                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
+2601                _ => None,
+2602            })
+2603            .collect();
+2604        assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
+2605    }
+2606
+2607    #[test]
+2608    fn test_html5_play_and_pause_emit_control_commands() {
+2609        let controller = PlayerController::default();
+2610        let emitter = Arc::new(CapturingEmitter::new());
+2611        controller.set_event_emitter(emitter.clone());
+2612        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
+2613
+2614        controller.play().unwrap();
+2615        controller.pause().unwrap();
+2616
+2617        let controls: Vec<_> = emitter
+2618            .events()
+2619            .into_iter()
+2620            .filter_map(|e| match e {
+2621                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
+2622                _ => None,
+2623            })
+2624            .collect();
+2625        assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
+2626    }
+2627
+2628    #[test]
+2629    fn test_background_audio_handoff_moves_transport_to_native_backend() {
+2630        // Lockscreen pause while playing a video's audio in the background.
+2631        //
+2632        // The handoff tears the WebView <video> down AFTER native audio starts,
+2633        // and that teardown fires a DOM `pause` the frontend dutifully reports.
+2634        // That report used to leave `html5_playing = Some(false)`, so transport
+2635        // kept being aimed at an element that no longer exists: the lockscreen
+2636        // pause emitted a ControlCommand into the void and the audio played on.
+2637        let controller = PlayerController::default();
+2638        let emitter = Arc::new(CapturingEmitter::new());
+2639        controller.set_event_emitter(emitter.clone());
+2640
+2641        // Video was playing in the webview.
+2642        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
+2643        assert!(controller.is_html5_active());
+2644
+2645        // Hand off to the native audio player, then tear the element down.
+2646        controller.enter_background_audio(1200.0);
+2647        controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
+2648
+2649        assert!(
+2650            !controller.is_html5_active(),
+2651            "native audio owns transport during a background-audio handoff"
+2652        );
+2653
+2654        controller.pause().unwrap();
+2655        let controls: Vec<_> = emitter
+2656            .events()
+2657            .into_iter()
+2658            .filter_map(|e| match e {
+2659                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
+2660                _ => None,
+2661            })
+2662            .collect();
+2663        assert!(
+2664            controls.is_empty(),
+2665            "pause must drive the native backend, not a torn-down element: {:?}",
+2666            controls
+2667        );
+2668    }
+2669
+2670    #[test]
+2671    fn test_background_audio_handoff_suppresses_stale_element_events() {
+2672        // The dying element's pause/position reports describe the video, not the
+2673        // audio now playing — re-emitting them flips the UI to paused and yanks
+2674        // the position backwards while native audio keeps going.
+2675        let controller = PlayerController::default();
+2676        let emitter = Arc::new(CapturingEmitter::new());
+2677        controller.set_event_emitter(emitter.clone());
+2678
+2679        controller.enter_background_audio(1200.0);
+2680        controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
+2681        controller.report_html5_position(1200.0, 2400.0);
+2682
+2683        assert!(
+2684            emitter.events().is_empty(),
+2685            "stale webview reports must not reach the event pipeline: {:?}",
+2686            emitter.events()
+2687        );
+2688    }
+2689
+2690    #[test]
+2691    fn test_exit_background_audio_returns_transport_to_the_webview() {
+2692        // Back in the foreground the <video> is the player again, so its reports
+2693        // must be honoured — and the base offset still comes back for the resume.
+2694        let controller = PlayerController::default();
+2695        let emitter = Arc::new(CapturingEmitter::new());
+2696        controller.set_event_emitter(emitter.clone());
+2697
+2698        controller.enter_background_audio(1200.0);
+2699        assert_eq!(controller.exit_background_audio(), 1200.0);
+2700
+2701        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
+2702        assert!(controller.is_html5_active());
+2703        assert!(controller.html5_is_playing());
+2704    }
+2705
+2706    #[test]
+2707    fn test_html5_stopped_report_releases_transport_to_native_backend() {
+2708        // When webview video goes away, transport must fall back to the native
+2709        // backend (music playback must not keep emitting ControlCommands).
+2710        let controller = PlayerController::default();
+2711        let emitter = Arc::new(CapturingEmitter::new());
+2712        controller.set_event_emitter(emitter.clone());
+2713
+2714        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
+2715        assert!(controller.is_html5_active());
+2716
+2717        controller.report_html5_state("stopped".to_string(), None);
+2718        assert!(!controller.is_html5_active());
+2719    }
+2720
+2721    #[test]
+2722    fn test_html5_transport_emits_exactly_one_control_per_intent() {
+2723        // Guards against a double-drive on platforms where the *backend* is also
+2724        // webview-based (WebviewAudioBackend on Windows): the html5 short-circuit
+2725        // must replace the backend call, not run in addition to it.
+2726        let controller = PlayerController::default();
+2727        let emitter = Arc::new(CapturingEmitter::new());
+2728        controller.set_event_emitter(emitter.clone());
+2729        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
+2730
+2731        controller.pause().unwrap();
+2732
+2733        let controls = emitter
+2734            .events()
+2735            .into_iter()
+2736            .filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
+2737            .count();
+2738        assert_eq!(controls, 1, "one intent must produce exactly one control");
+2739    }
+2740
+2741    #[test]
+2742    fn test_controller_volume_default() {
+2743        let controller = PlayerController::default();
+2744        assert_eq!(controller.volume(), 1.0);
+2745    }
+2746
+2747    #[test]
+2748    fn test_controller_set_volume() {
+2749        let controller = PlayerController::default();
+2750        controller.set_volume(0.5).unwrap();
+2751        assert_eq!(controller.volume(), 0.5);
+2752    }
+2753
+2754    #[test]
+2755    fn test_controller_muted_default() {
+2756        let controller = PlayerController::default();
+2757        assert!(!controller.muted());
+2758    }
+2759
+2760    #[test]
+2761    fn test_controller_volume_delegates_to_backend() {
+2762        let controller = PlayerController::default();
+2763
+2764        // Set volume through controller
+2765        controller.set_volume(0.75).unwrap();
+2766
+2767        // Verify it's reflected in both controller.volume() and backend
+2768        assert_eq!(controller.volume(), 0.75);
+2769    }
+2770
+2771    fn create_test_items(count: usize) -> Vec<MediaItem> {
+2772        (0..count)
+2773            .map(|i| MediaItem {
+2774                // Audio and direct-URL items never negotiate a transport.
+2775                transport: None,
+2776                id: format!("item_{}", i),
+2777                title: format!("Track {}", i + 1),
+2778                name: Some(format!("Track {}", i + 1)),
+2779                artist: Some("Test Artist".to_string()),
+2780                album: Some("Test Album".to_string()),
+2781                album_name: Some("Test Album".to_string()),
+2782                album_id: None,
+2783                artist_items: None,
+2784                artists: Some(vec!["Test Artist".to_string()]),
+2785                primary_image_tag: None,
+2786                image_id: None,
+2787                item_type: Some("Audio".to_string()),
+2788                playlist_id: None,
+2789                duration: Some(180.0),
+2790                artwork_url: None,
+2791                media_type: MediaType::Audio,
+2792                source: MediaSource::DirectUrl {
+2793                    url: format!("http://example.com/track_{}.mp3", i),
+2794                },
+2795                video_codec: None,
+2796                needs_transcoding: false,
+2797                video_width: None,
+2798                video_height: None,
+2799                subtitles: vec![],
+2800                series_id: None,
+2801                server_id: None,
+2802            })
+2803            .collect()
+2804    }
+2805
+2806    #[test]
+2807    fn test_skip_preserves_queue() {
+2808        let controller = PlayerController::default();
+2809
+2810        // Create a queue with 5 items
+2811        let items = create_test_items(5);
+2812        let items_clone = items.clone();
+2813
+2814        // Play the queue starting at index 0
+2815        controller.play_queue(items, 0).unwrap();
+2816
+2817        // Verify initial state
+2818        {
+2819            let queue = controller.queue();
+2820            let queue_lock = queue.lock_safe();
+2821            assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
+2822            assert_eq!(
+2823                queue_lock.current_index(),
+2824                Some(0),
+2825                "Should start at index 0"
+2826            );
+2827            assert_eq!(
+2828                queue_lock.current().unwrap().id,
+2829                "item_0",
+2830                "Current item should be item_0"
+2831            );
+2832        }
+2833
+2834        // Skip to next track
+2835        controller.next().unwrap();
+2836
+2837        // Verify queue is intact and index advanced
+2838        {
+2839            let queue = controller.queue();
+2840            let queue_lock = queue.lock_safe();
+2841            assert_eq!(
+2842                queue_lock.items().len(),
+2843                5,
+2844                "Queue should still have 5 items after skip"
+2845            );
+2846            assert_eq!(
+2847                queue_lock.current_index(),
+2848                Some(1),
+2849                "Index should advance to 1"
+2850            );
+2851            assert_eq!(
+2852                queue_lock.current().unwrap().id,
+2853                "item_1",
+2854                "Current item should be item_1"
+2855            );
+2856
+2857            // Verify all original items are still present
+2858            let current_items = queue_lock.items();
+2859            for (i, original) in items_clone.iter().enumerate() {
+2860                assert_eq!(
+2861                    current_items[i].id, original.id,
+2862                    "Item {} should still be in queue",
+2863                    i
+2864                );
+2865                assert_eq!(
+2866                    current_items[i].title, original.title,
+2867                    "Item {} title should be unchanged",
+2868                    i
+2869                );
+2870            }
+2871        }
+2872
+2873        // Skip again
+2874        controller.next().unwrap();
+2875
+2876        // Verify queue still intact and index advanced again
+2877        {
+2878            let queue = controller.queue();
+2879            let queue_lock = queue.lock_safe();
+2880            assert_eq!(
+2881                queue_lock.items().len(),
+2882                5,
+2883                "Queue should still have 5 items after second skip"
+2884            );
+2885            assert_eq!(
+2886                queue_lock.current_index(),
+2887                Some(2),
+2888                "Index should advance to 2"
+2889            );
+2890            assert_eq!(
+2891                queue_lock.current().unwrap().id,
+2892                "item_2",
+2893                "Current item should be item_2"
+2894            );
+2895        }
+2896
+2897        // Skip multiple times to reach the end
+2898        controller.next().unwrap(); // -> item_3
+2899        controller.next().unwrap(); // -> item_4
+2900
+2901        // Verify we're at the last item
+2902        {
+2903            let queue = controller.queue();
+2904            let queue_lock = queue.lock_safe();
+2905            assert_eq!(
+2906                queue_lock.items().len(),
+2907                5,
+2908                "Queue should still have 5 items at end"
+2909            );
+2910            assert_eq!(
+2911                queue_lock.current_index(),
+2912                Some(4),
+2913                "Index should be at last item (4)"
+2914            );
+2915            assert_eq!(
+2916                queue_lock.current().unwrap().id,
+2917                "item_4",
+2918                "Current item should be item_4"
+2919            );
+2920        }
+2921    }
+2922
+2923    #[test]
+2924    fn test_skip_at_end_without_repeat() {
+2925        let controller = PlayerController::default();
+2926
+2927        // Create a queue with 3 items
+2928        let items = create_test_items(3);
+2929        controller.play_queue(items, 0).unwrap();
+2930
+2931        // Skip to last item
+2932        controller.next().unwrap(); // -> item_1
+2933        controller.next().unwrap(); // -> item_2
+2934
+2935        // Verify we're at the last item
+2936        {
+2937            let queue = controller.queue();
+2938            let queue_lock = queue.lock_safe();
+2939            assert_eq!(
+2940                queue_lock.current_index(),
+2941                Some(2),
+2942                "Should be at last item"
+2943            );
+2944        }
+2945
+2946        // Try to skip past the end (without repeat mode)
+2947        // This should succeed but stop playback while preserving the queue
+2948        controller.next().unwrap();
+2949
+2950        // Verify queue is still intact
+2951        {
+2952            let queue = controller.queue();
+2953            let queue_lock = queue.lock_safe();
+2954            assert_eq!(
+2955                queue_lock.items().len(),
+2956                3,
+2957                "Queue should still have 3 items after skip at end"
+2958            );
+2959            // When we skip past the end, the queue index should stay at the last item
+2960            // or become None (depending on implementation)
+2961            // The key is the queue items themselves should be preserved
+2962        }
+2963    }
+2964
+2965    #[test]
+2966    fn test_skip_with_repeat_all() {
+2967        let controller = PlayerController::default();
+2968
+2969        // Create a queue with 3 items
+2970        let items = create_test_items(3);
+2971        controller.play_queue(items, 0).unwrap();
+2972
+2973        // Enable repeat all
+2974        controller.cycle_repeat();
+2975
+2976        // Skip to last item
+2977        controller.next().unwrap(); // -> item_1
+2978        controller.next().unwrap(); // -> item_2
+2979
+2980        // Skip again - should wrap to beginning
+2981        controller.next().unwrap();
+2982
+2983        // Verify we wrapped to the first item
+2984        {
+2985            let queue = controller.queue();
+2986            let queue_lock = queue.lock_safe();
+2987            assert_eq!(
+2988                queue_lock.items().len(),
+2989                3,
+2990                "Queue should still have 3 items"
+2991            );
+2992            assert_eq!(
+2993                queue_lock.current_index(),
+2994                Some(0),
+2995                "Should wrap to index 0"
+2996            );
+2997            assert_eq!(
+2998                queue_lock.current().unwrap().id,
+2999                "item_0",
+3000                "Should be back at item_0"
+3001            );
+3002        }
+3003    }
+3004
+3005    #[test]
+3006    fn test_previous_preserves_queue() {
+3007        let controller = PlayerController::default();
+3008
+3009        // Create a queue with 5 items, start at item 3
+3010        let items = create_test_items(5);
+3011        let items_clone = items.clone();
+3012        controller.play_queue(items, 3).unwrap();
+3013
+3014        // Verify starting position
+3015        {
+3016            let queue = controller.queue();
+3017            let queue_lock = queue.lock_safe();
+3018            assert_eq!(
+3019                queue_lock.current_index(),
+3020                Some(3),
+3021                "Should start at index 3"
+3022            );
+3023        }
+3024
+3025        // Go to previous track
+3026        controller.previous().unwrap();
+3027
+3028        // Verify queue is intact and index moved back
+3029        {
+3030            let queue = controller.queue();
+3031            let queue_lock = queue.lock_safe();
+3032            assert_eq!(
+3033                queue_lock.items().len(),
+3034                5,
+3035                "Queue should still have 5 items after previous"
+3036            );
+3037            assert_eq!(
+3038                queue_lock.current_index(),
+3039                Some(2),
+3040                "Index should move to 2"
+3041            );
+3042            assert_eq!(
+3043                queue_lock.current().unwrap().id,
+3044                "item_2",
+3045                "Current item should be item_2"
+3046            );
+3047
+3048            // Verify all original items are still present
+3049            let current_items = queue_lock.items();
+3050            for (i, original) in items_clone.iter().enumerate() {
+3051                assert_eq!(
+3052                    current_items[i].id, original.id,
+3053                    "Item {} should still be in queue",
+3054                    i
+3055                );
+3056            }
+3057        }
+3058    }
+3059
+3060    #[test]
+3061    fn test_seek_updates_position() {
+3062        let controller = PlayerController::default();
+3063
+3064        // Create and play a single item
+3065        let item = create_test_items(1).into_iter().next().unwrap();
+3066        controller.play_item(item).unwrap();
+3067
+3068        // Verify initial position
+3069        assert_eq!(controller.position(), 0.0, "Initial position should be 0");
+3070
+3071        // Seek to 30 seconds
+3072        controller.seek(30.0).unwrap();
+3073        assert_eq!(
+3074            controller.position(),
+3075            30.0,
+3076            "Position should be 30 after seeking"
+3077        );
+3078
+3079        // Seek to 60 seconds
+3080        controller.seek(60.0).unwrap();
+3081        assert_eq!(
+3082            controller.position(),
+3083            60.0,
+3084            "Position should be 60 after seeking"
+3085        );
+3086
+3087        // Seek backward to 15 seconds
+3088        controller.seek(15.0).unwrap();
+3089        assert_eq!(
+3090            controller.position(),
+3091            15.0,
+3092            "Position should be 15 after seeking backward"
+3093        );
+3094    }
+3095
+3096    #[test]
+3097    fn test_seek_while_paused() {
+3098        let controller = PlayerController::default();
+3099
+3100        // Create and play a single item
+3101        let item = create_test_items(1).into_iter().next().unwrap();
+3102        controller.play_item(item).unwrap();
+3103
+3104        // Pause playback
+3105        controller.pause().unwrap();
+3106
+3107        // Verify paused state
+3108        assert!(controller.state().is_paused(), "Should be paused");
+3109
+3110        // Seek while paused
+3111        controller.seek(45.0).unwrap();
+3112        assert_eq!(
+3113            controller.position(),
+3114            45.0,
+3115            "Position should update while paused"
+3116        );
+3117
+3118        // Verify still paused after seeking
+3119        assert!(
+3120            controller.state().is_paused(),
+3121            "Should still be paused after seeking"
+3122        );
+3123    }
+3124
+3125    #[test]
+3126    fn test_seek_while_playing() {
+3127        let controller = PlayerController::default();
+3128
+3129        // Create and play a single item
+3130        let item = create_test_items(1).into_iter().next().unwrap();
+3131        controller.play_item(item).unwrap();
+3132
+3133        // Ensure playing
+3134        controller.play().unwrap();
+3135
+3136        // Verify playing state
+3137        assert!(controller.state().is_playing(), "Should be playing");
+3138
+3139        // Seek while playing
+3140        controller.seek(20.0).unwrap();
+3141        assert_eq!(
+3142            controller.position(),
+3143            20.0,
+3144            "Position should update while playing"
+3145        );
+3146
+3147        // Verify still playing after seeking
+3148        assert!(
+3149            controller.state().is_playing(),
+3150            "Should still be playing after seeking"
+3151        );
+3152    }
+3153
+3154    #[test]
+3155    fn test_multiple_sequential_seeks() {
+3156        let controller = PlayerController::default();
+3157
+3158        let item = create_test_items(1).into_iter().next().unwrap();
+3159        controller.play_item(item).unwrap();
+3160
+3161        // Perform multiple seeks in sequence
+3162        let positions = vec![10.0, 25.0, 50.0, 75.0, 100.0, 30.0];
+3163
+3164        for pos in positions {
+3165            controller.seek(pos).unwrap();
+3166            assert_eq!(
+3167                controller.position(),
+3168                pos,
+3169                "Position should match after seeking to {}",
+3170                pos
+3171            );
+3172        }
+3173    }
+3174
+3175    /// Resuming a queue at a position seeks the starting track immediately.
+3176    /// Regression guard for taking over a remote session: the local player must
+3177    /// pick up where the remote left off, not restart from 0.
+3178    #[test]
+3179    fn test_play_queue_from_resumes_at_position() {
+3180        let controller = PlayerController::default();
+3181        let items = create_test_items(3);
+3182
+3183        controller.play_queue_from(items, 1, Some(42.5)).unwrap();
+3184
+3185        {
+3186            let queue = controller.queue();
+3187            let queue_lock = queue.lock_safe();
+3188            assert_eq!(
+3189                queue_lock.current_index(),
+3190                Some(1),
+3191                "Should start at index 1"
+3192            );
+3193        }
+3194        assert_eq!(
+3195            controller.position(),
+3196            42.5,
+3197            "Should resume at the requested position"
+3198        );
+3199    }
+3200
+3201    /// A None / near-zero start position starts the track from the beginning.
+3202    #[test]
+3203    fn test_play_queue_from_without_position_starts_at_zero() {
+3204        let controller = PlayerController::default();
+3205
+3206        controller
+3207            .play_queue_from(create_test_items(2), 0, None)
+3208            .unwrap();
+3209        assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
+3210
+3211        controller
+3212            .play_queue_from(create_test_items(2), 0, Some(0.2))
+3213            .unwrap();
+3214        assert_eq!(
+3215            controller.position(),
+3216            0.0,
+3217            "Sub-threshold resume position is ignored (starts at 0)"
+3218        );
+3219    }
+3220
+3221    #[test]
+3222    fn test_seek_to_zero() {
+3223        let controller = PlayerController::default();
+3224
+3225        let item = create_test_items(1).into_iter().next().unwrap();
+3226        controller.play_item(item).unwrap();
+3227
+3228        // Seek forward
+3229        controller.seek(60.0).unwrap();
+3230        assert_eq!(controller.position(), 60.0);
+3231
+3232        // Seek back to zero
+3233        controller.seek(0.0).unwrap();
+3234        assert_eq!(
+3235            controller.position(),
+3236            0.0,
+3237            "Should be able to seek to position 0"
+3238        );
+3239    }
+3240
+3241    // Autoplay decision tests
+3242    #[tokio::test]
+3243    async fn test_audio_with_next_advances() {
+3244        let controller = PlayerController::default();
+3245
+3246        // Create queue with 2 audio items
+3247        let items = create_test_items(2);
+3248        controller.play_queue(items, 0).unwrap();
+3249
+3250        // Clear the NewTrackLoaded reason set by play_queue to simulate natural track end
+3251        controller.take_end_reason();
+3252
+3253        // Simulate first track ending naturally
+3254        let decision = controller.on_playback_ended().await.unwrap();
+3255
+3256        // Should decide to advance to next
+3257        assert!(
+3258            matches!(decision, AutoplayDecision::AdvanceToNext),
+3259            "Expected AdvanceToNext decision when queue has next item"
+3260        );
+3261    }
+3262
+3263    #[tokio::test]
+3264    async fn test_audio_at_end_stops() {
+3265        let controller = PlayerController::default();
+3266
+3267        // Create queue with 2 items, start at last one
+3268        let items = create_test_items(2);
+3269        controller.play_queue(items, 1).unwrap();
+3270
+3271        // Clear the NewTrackLoaded reason to simulate natural track end
+3272        controller.take_end_reason();
+3273
+3274        // Simulate last track ending naturally
+3275        let decision = controller.on_playback_ended().await.unwrap();
+3276
+3277        // Should decide to stop (no more items)
+3278        assert!(
+3279            matches!(decision, AutoplayDecision::Stop),
+3280            "Expected Stop decision when at end of queue without repeat"
+3281        );
+3282    }
+3283
+3284    #[tokio::test]
+3285    async fn test_sleep_timer_end_of_track() {
+3286        let controller = PlayerController::default();
+3287
+3288        // Create queue with next items
+3289        let items = create_test_items(3);
+3290        controller.play_queue(items, 0).unwrap();
+3291
+3292        // Clear the NewTrackLoaded reason to simulate natural track end
+3293        controller.take_end_reason();
+3294
+3295        // Set sleep timer to end of track
+3296        {
+3297            let mut timer = controller.sleep_timer.lock_safe();
+3298            timer.mode = SleepTimerMode::EndOfTrack;
+3299        }
+3300
+3301        // Simulate track ending naturally
+3302        let decision = controller.on_playback_ended().await.unwrap();
+3303
+3304        // Should stop despite having next items
+3305        assert!(
+3306            matches!(decision, AutoplayDecision::Stop),
+3307            "Expected Stop decision when sleep timer is EndOfTrack"
+3308        );
+3309
+3310        // Verify timer was cancelled
+3311        {
+3312            let timer = controller.sleep_timer.lock_safe();
+3313            assert!(
+3314                matches!(timer.mode, SleepTimerMode::Off),
+3315                "Sleep timer should be cancelled after EndOfTrack"
+3316            );
+3317        }
+3318    }
+3319
+3320    /// A time-based sleep timer that fires mid-episode must not let the ended
+3321    /// callback fall through to autoplay.
+3322    ///
+3323    /// The timer thread stops the backend directly, which makes ExoPlayer emit
+3324    /// its ended callback. That callback races the thread's own `timer.cancel()`:
+3325    /// by the time `on_playback_ended` inspects the sleep timer it reads `Off`,
+3326    /// so the timer branch is skipped and the episode path runs — showing a
+3327    /// next-episode popup (or advancing) after the user's sleep timer expired.
+3328    #[tokio::test]
+3329    async fn test_expired_time_sleep_timer_stops_without_autoplay() {
+3330        let controller = PlayerController::default();
+3331
+3332        let items = create_test_items(3);
+3333        controller.play_queue(items, 0).unwrap();
+3334        controller.take_end_reason();
+3335
+3336        // Arm a time-based timer that is already due, then let the real timer
+3337        // thread (started in the constructor, 1s tick) observe the expiry and
+3338        // run its stop path. Driving the actual thread is the point: the bug was
+3339        // that this path stopped the backend without recording an end reason.
+3340        let now = chrono::Utc::now().timestamp_millis();
+3341        controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
+3342
+3343        // Wait for the timer thread to process the expiry (tick is 1s).
+3344        for _ in 0..40 {
+3345            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+3346            if !controller.sleep_timer.lock_safe().is_active() {
+3347                break;
+3348            }
+3349        }
+3350        assert!(
+3351            !controller.sleep_timer.lock_safe().is_active(),
+3352            "Timer thread should have expired and cancelled the sleep timer"
+3353        );
+3354
+3355        // The backend stop above makes the native player fire its ended callback.
+3356        let decision = controller.on_playback_ended().await.unwrap();
+3357
+3358        assert!(
+3359            matches!(decision, AutoplayDecision::Stop),
+3360            "Expected Stop after an expired time-based sleep timer, got {:?}",
+3361            decision
+3362        );
+3363    }
+3364
+3365    #[tokio::test]
+3366    async fn test_empty_queue_stops() {
+3367        let controller = PlayerController::default();
+3368
+3369        // Don't set up any queue
+3370        let decision = controller.on_playback_ended().await.unwrap();
+3371
+3372        // Should stop (no current item)
+3373        assert!(
+3374            matches!(decision, AutoplayDecision::Stop),
+3375            "Expected Stop decision when queue is empty"
+3376        );
+3377    }
+3378
+3379    #[tokio::test]
+3380    async fn test_repeat_all_advances_at_end() {
+3381        let controller = PlayerController::default();
+3382
+3383        // Create queue with 2 items, enable repeat all
+3384        let items = create_test_items(2);
+3385        controller.play_queue(items, 1).unwrap(); // Start at last item
+3386        controller.cycle_repeat(); // Enable repeat all
+3387
+3388        // Clear the NewTrackLoaded reason to simulate natural track end
+3389        controller.take_end_reason();
+3390
+3391        // Simulate last track ending naturally
+3392        let decision = controller.on_playback_ended().await.unwrap();
+3393
+3394        // Should advance (will wrap to beginning due to repeat all)
+3395        assert!(
+3396            matches!(decision, AutoplayDecision::AdvanceToNext),
+3397            "Expected AdvanceToNext decision at end of queue with repeat all"
+3398        );
+3399    }
+3400
+3401    #[tokio::test]
+3402    async fn test_repeat_one_advances() {
+3403        let controller = PlayerController::default();
+3404
+3405        // Create queue with 2 items
+3406        let items = create_test_items(2);
+3407        controller.play_queue(items, 0).unwrap();
+3408
+3409        // Enable repeat one
+3410        controller.cycle_repeat(); // Once for all
+3411        controller.cycle_repeat(); // Twice for one
+3412
+3413        // Clear the NewTrackLoaded reason to simulate natural track end
+3414        controller.take_end_reason();
+3415
+3416        // Simulate track ending naturally
+3417        let decision = controller.on_playback_ended().await.unwrap();
+3418
+3419        // Should advance (which repeats the same track)
+3420        assert!(
+3421            matches!(decision, AutoplayDecision::AdvanceToNext),
+3422            "Expected AdvanceToNext decision with repeat one (repeats same track)"
+3423        );
+3424    }
+3425
+3426    #[test]
+3427    fn test_native_load_returns_transport_authority_to_the_backend() {
+3428        // Play/pause did nothing on the Android native video path, from the
+3429        // on-screen tap AND from the control-bar button, while seek and skip
+3430        // worked — those take a different decision path.
+3431        //
+3432        // `html5_playing` is written only by the webview element's own reports
+3433        // and cleared only when it reports "stopped"/"idle" (or on a
+3434        // background-audio handoff). A previous element that went away without
+3435        // that final report — or webview-rendered music earlier in the same
+3436        // process — therefore left `is_html5_active()` true, and every transport
+3437        // intent was emitted as a ControlCommand at an element that no longer
+3438        // existed. Nothing reached ExoPlayer. It looked intermittent because it
+3439        // depends entirely on what played before.
+3440        //
+3441        // Loading into the native backend IS the statement that native renders
+3442        // this item, so it hands authority back — the same "element is gone"
+3443        // semantics the "stopped"/"idle" report already has.
+3444        //
+3445        // TRACES: UR-005, UR-003 | DR-193
+3446        let controller = PlayerController::default();
+3447        let emitter = Arc::new(CapturingEmitter::new());
+3448        controller.set_event_emitter(emitter.clone());
+3449
+3450        // A webview element reported itself playing and never said "stopped".
+3451        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
+3452        assert!(controller.is_html5_active());
+3453
+3454        // Now a native item loads — Android video through ExoPlayer.
+3455        let item = create_test_items(1).into_iter().next().unwrap();
+3456        controller.play_item(item).unwrap();
+3457
+3458        assert!(
+3459            !controller.is_html5_active(),
+3460            "loading into the native backend hands transport back to it"
+3461        );
+3462
+3463        // The toggle must reach the backend, not be emitted at a dead element.
+3464        controller.toggle_playback().unwrap();
+3465        let controls: Vec<_> = emitter
+3466            .events()
+3467            .into_iter()
+3468            .filter_map(|e| match e {
+3469                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
+3470                _ => None,
+3471            })
+3472            .collect();
+3473        assert!(
+3474            controls.is_empty(),
+3475            "transport went to a webview element that is not rendering: {controls:?}"
+3476        );
+3477    }
+3478
+3479    // EndReason state machine tests
+3480    #[test]
+3481    fn test_load_and_play_sets_new_track_loaded() {
+3482        let controller = PlayerController::default();
+3483        let item = create_test_items(1).into_iter().next().unwrap();
+3484
+3485        // End reason should be None initially
+3486        assert!(controller.take_end_reason().is_none());
+3487
+3488        // Load and play should set NewTrackLoaded
+3489        controller.load_and_play(&item).unwrap();
+3490
+3491        // Verify end reason was set
+3492        let reason = controller.take_end_reason();
+3493        assert_eq!(reason, Some(EndReason::NewTrackLoaded));
+3494    }
+3495
+3496    #[test]
+3497    fn test_stop_sets_user_stop() {
+3498        let controller = PlayerController::default();
+3499        let item = create_test_items(1).into_iter().next().unwrap();
+3500
+3501        // Play an item first
+3502        controller.play_item(item).unwrap();
+3503
+3504        // Clear any end reason from load_and_play
+3505        controller.take_end_reason();
+3506
+3507        // Stop should set UserStop
+3508        controller.stop().unwrap();
+3509
+3510        // Verify end reason was set
+3511        let reason = controller.take_end_reason();
+3512        assert_eq!(reason, Some(EndReason::UserStop));
+3513    }
+3514
+3515    #[tokio::test]
+3516    async fn test_on_playback_ended_with_new_track_loaded_stops() {
+3517        let controller = PlayerController::default();
+3518
+3519        // Create queue with 2 items
+3520        let items = create_test_items(2);
+3521        controller.play_queue(items, 0).unwrap();
+3522
+3523        // Manually set end reason to NewTrackLoaded
+3524        controller.set_end_reason(EndReason::NewTrackLoaded);
+3525
+3526        // Call on_playback_ended
+3527        let decision = controller.on_playback_ended().await.unwrap();
+3528
+3529        // Should stop without advancing
+3530        assert!(
+3531            matches!(decision, AutoplayDecision::Stop),
+3532            "Expected Stop decision when EndReason is NewTrackLoaded"
+3533        );
+3534    }
+3535
+3536    #[tokio::test]
+3537    async fn test_on_playback_ended_with_user_stop_stops() {
+3538        let controller = PlayerController::default();
+3539
+3540        // Create queue with 2 items
+3541        let items = create_test_items(2);
+3542        controller.play_queue(items, 0).unwrap();
+3543
+3544        // Manually set end reason to UserStop
+3545        controller.set_end_reason(EndReason::UserStop);
+3546
+3547        // Call on_playback_ended
+3548        let decision = controller.on_playback_ended().await.unwrap();
+3549
+3550        // Should stop without advancing
+3551        assert!(
+3552            matches!(decision, AutoplayDecision::Stop),
+3553            "Expected Stop decision when EndReason is UserStop"
+3554        );
+3555    }
+3556
+3557    #[tokio::test]
+3558    async fn test_on_playback_ended_natural_end_advances() {
+3559        let controller = PlayerController::default();
+3560
+3561        // Create queue with 2 items
+3562        let items = create_test_items(2);
+3563        controller.play_queue(items, 0).unwrap();
+3564
+3565        // Clear the NewTrackLoaded reason to simulate natural track end
+3566        controller.take_end_reason();
+3567
+3568        // Call on_playback_ended (no end reason set = natural end)
+3569        let decision = controller.on_playback_ended().await.unwrap();
+3570
+3571        // Should advance to next (natural end with next track available)
+3572        assert!(
+3573            matches!(decision, AutoplayDecision::AdvanceToNext),
+3574            "Expected AdvanceToNext decision when track ends naturally with next track available"
+3575        );
+3576    }
+3577
+3578    #[tokio::test]
+3579    async fn test_on_playback_ended_with_user_skip_stops() {
+3580        let controller = PlayerController::default();
+3581
+3582        // Create queue with 2 items
+3583        let items = create_test_items(2);
+3584        controller.play_queue(items, 0).unwrap();
+3585
+3586        // Set end reason to UserSkip
+3587        controller.set_end_reason(EndReason::UserSkip);
+3588
+3589        // Call on_playback_ended
+3590        let decision = controller.on_playback_ended().await.unwrap();
+3591
+3592        // Should stop without advancing (skip already handled)
+3593        assert!(
+3594            matches!(decision, AutoplayDecision::Stop),
+3595            "Expected Stop decision when EndReason is UserSkip"
+3596        );
+3597    }
+3598
+3599    #[tokio::test]
+3600    async fn test_on_playback_ended_with_error_stops() {
+3601        let controller = PlayerController::default();
+3602
+3603        // Create queue with 2 items
+3604        let items = create_test_items(2);
+3605        controller.play_queue(items, 0).unwrap();
+3606
+3607        // Set end reason to Error
+3608        controller.set_end_reason(EndReason::Error);
+3609
+3610        // Call on_playback_ended
+3611        let decision = controller.on_playback_ended().await.unwrap();
+3612
+3613        // Should stop without advancing
+3614        assert!(
+3615            matches!(decision, AutoplayDecision::Stop),
+3616            "Expected Stop decision when EndReason is Error"
+3617        );
+3618    }
+3619
+3620    #[tokio::test]
+3621    async fn test_take_end_reason_clears_state() {
+3622        let controller = PlayerController::default();
+3623
+3624        // Set a reason
+3625        controller.set_end_reason(EndReason::NewTrackLoaded);
+3626
+3627        // Take it once
+3628        let reason = controller.take_end_reason();
+3629        assert_eq!(reason, Some(EndReason::NewTrackLoaded));
+3630
+3631        // Take it again - should be None
+3632        let reason = controller.take_end_reason();
+3633        assert!(reason.is_none(), "take_end_reason should clear the state");
+3634    }
+3635
+3636    // ===== Next-episode autoplay decision tests =====
+3637
+3638    use crate::repository::types as repo_types;
+3639
+3640    /// Mock repository serving a single season of episodes for next-episode
+3641    /// lookup tests. Only `get_item` and `get_items` are used by
+3642    /// `fetch_next_episode_for_item`; everything else is unreachable.
+3643    struct MockEpisodeRepo {
+3644        episodes: Vec<repo_types::MediaItem>,
+3645    }
+3646
+3647    impl MockEpisodeRepo {
+3648        fn season(count: usize) -> Self {
+3649            let episodes = (1..=count)
+3650                .map(|i| {
+3651                    let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
+3652                    item.name = format!("Episode {}", i);
+3653                    item
+3654                })
+3655                .collect();
+3656            Self { episodes }
+3657        }
+3658    }
+3659
+3660    fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
+3661        repo_types::MediaItem {
+3662            id: id.to_string(),
+3663            name: format!("Episode {}", index),
+3664            item_type: "Episode".to_string(),
+3665            kind: crate::domain::MediaKind::Episode,
+3666            is_folder: false,
+3667            server_id: "server".to_string(),
+3668            parent_id: Some("season1".to_string()),
+3669            library_id: None,
+3670            overview: None,
+3671            genres: None,
+3672            runtime_ticks: None,
+3673            duration_ms: None,
+3674            production_year: None,
+3675            premiere_date: None,
+3676            community_rating: None,
+3677            official_rating: None,
+3678            primary_image_tag: None,
+3679            image_id: None,
+3680            backdrop_image_tags: None,
+3681            parent_backdrop_image_tags: None,
+3682            album_id: None,
+3683            album_name: None,
+3684            album_artist: None,
+3685            artists: None,
+3686            artist_items: None,
+3687            index_number: Some(index),
+3688            series_id: Some("series1".to_string()),
+3689            series_name: Some("Test Series".to_string()),
+3690            season_id: Some("season1".to_string()),
+3691            season_name: Some("Season 1".to_string()),
+3692            parent_index_number: Some(1),
+3693            user_data: None,
+3694            media_streams: None,
+3695            media_sources: None,
+3696            people: None,
+3697        }
+3698    }
+3699
+3700    #[async_trait::async_trait]
+3701    impl crate::repository::MediaRepository for MockEpisodeRepo {
+3702        async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
+3703            unimplemented!()
+3704        }
+3705        async fn get_items(
+3706            &self,
+3707            parent_id: &str,
+3708            _options: Option<repo_types::GetItemsOptions>,
+3709        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
+3710            assert_eq!(parent_id, "season1", "episode lookup must query the season");
+3711            Ok(repo_types::SearchResult {
+3712                items: self.episodes.clone(),
+3713                total_record_count: self.episodes.len(),
+3714            })
+3715        }
+3716        async fn get_item(
+3717            &self,
+3718            item_id: &str,
+3719        ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
+3720            self.episodes
+3721                .iter()
+3722                .find(|e| e.id == item_id)
+3723                .cloned()
+3724                .ok_or(repo_types::RepoError::NotFound {
+3725                    message: format!("{} not found", item_id),
+3726                })
+3727        }
+3728        async fn get_latest_items(
+3729            &self,
+3730            _: &str,
+3731            _: Option<usize>,
+3732        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
+3733            unimplemented!()
+3734        }
+3735        async fn get_resume_items(
+3736            &self,
+3737            _: Option<&str>,
+3738            _: Option<usize>,
+3739        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
+3740            unimplemented!()
+3741        }
+3742        async fn get_next_up_episodes(
+3743            &self,
+3744            _: Option<&str>,
+3745            _: Option<usize>,
+3746        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
+3747            unimplemented!()
+3748        }
+3749        async fn get_recently_played_audio(
+3750            &self,
+3751            _: Option<usize>,
+3752        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
+3753            unimplemented!()
+3754        }
+3755        async fn get_rediscover_albums(
+3756            &self,
+3757            _: Option<&str>,
+3758            _: Option<usize>,
+3759        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
+3760            unimplemented!()
+3761        }
+3762        async fn get_resume_movies(
+3763            &self,
+3764            _: Option<usize>,
+3765        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
+3766            unimplemented!()
+3767        }
+3768        async fn get_genres(
+3769            &self,
+3770            _: Option<&str>,
+3771        ) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
+3772            unimplemented!()
+3773        }
+3774        async fn search(
+3775            &self,
+3776            _: &str,
+3777            _: Option<repo_types::SearchOptions>,
+3778        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
+3779            unimplemented!()
+3780        }
+3781        async fn get_playback_info(
+3782            &self,
+3783            _: &str,
+3784        ) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
+3785            unimplemented!()
+3786        }
+3787        async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
+3788            unimplemented!()
+3789        }
+3790        async fn get_audio_only_stream_url_for_video(
+3791            &self,
+3792            item_id: &str,
+3793            _media_source_id: Option<&str>,
+3794            _start_time_seconds: Option<f64>,
+3795            _audio_stream_index: Option<i32>,
+3796        ) -> Result<String, repo_types::RepoError> {
+3797            Ok(format!("http://example.com/{}-audio.mp3", item_id))
+3798        }
+3799        async fn get_live_tv_channels(
+3800            &self,
+3801        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
+3802            unimplemented!()
+3803        }
+3804        async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
+3805            unimplemented!()
+3806        }
+3807        async fn open_live_stream(
+3808            &self,
+3809            _: &str,
+3810        ) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
+3811            unimplemented!()
+3812        }
+3813        async fn report_playback_start(
+3814            &self,
+3815            _: &str,
+3816            _: i64,
+3817        ) -> Result<(), repo_types::RepoError> {
+3818            unimplemented!()
+3819        }
+3820        async fn report_playback_progress(
+3821            &self,
+3822            _: &str,
+3823            _: i64,
+3824        ) -> Result<(), repo_types::RepoError> {
+3825            unimplemented!()
+3826        }
+3827        async fn report_playback_stopped(
+3828            &self,
+3829            _: &str,
+3830            _: i64,
+3831        ) -> Result<(), repo_types::RepoError> {
+3832            unimplemented!()
+3833        }
+3834        fn get_image_url(
+3835            &self,
+3836            _: &str,
+3837            _: repo_types::ImageType,
+3838            _: Option<repo_types::ImageOptions>,
+3839        ) -> String {
+3840            unimplemented!()
+3841        }
+3842        fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
+3843            unimplemented!()
+3844        }
+3845        fn get_video_download_url(
+3846            &self,
+3847            _: &str,
+3848            _: &str,
+3849            _: Option<&str>,
+3850            _: Option<&str>,
+3851        ) -> String {
+3852            unimplemented!()
+3853        }
+3854        async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
+3855            unimplemented!()
+3856        }
+3857        async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
+3858            unimplemented!()
+3859        }
+3860        async fn get_favorites(
+3861            &self,
+3862            _: repo_types::SearchScope,
+3863            _: Option<repo_types::GetItemsOptions>,
+3864        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
+3865            unimplemented!()
+3866        }
+3867        async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
+3868            unimplemented!()
+3869        }
+3870        async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
+3871            unimplemented!()
+3872        }
+3873        async fn get_person(
+3874            &self,
+3875            _: &str,
+3876        ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
+3877            unimplemented!()
+3878        }
+3879        async fn get_items_by_person(
+3880            &self,
+3881            _: &str,
+3882            _: Option<repo_types::GetItemsOptions>,
+3883        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
+3884            unimplemented!()
+3885        }
+3886        async fn get_similar_items(
+3887            &self,
+3888            _: &str,
+3889            _: Option<usize>,
+3890        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
+3891            unimplemented!()
+3892        }
+3893        async fn create_playlist(
+3894            &self,
+3895            _: &str,
+3896            _: &[String],
+3897        ) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
+3898            unimplemented!()
+3899        }
+3900        async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
+3901            unimplemented!()
+3902        }
+3903        async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
+3904            unimplemented!()
+3905        }
+3906        async fn get_playlist_items(
+3907            &self,
+3908            _: &str,
+3909        ) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
+3910            unimplemented!()
+3911        }
+3912        async fn add_to_playlist(
+3913            &self,
+3914            _: &str,
+3915            _: &[String],
+3916        ) -> Result<(), repo_types::RepoError> {
+3917            unimplemented!()
+3918        }
+3919        async fn remove_from_playlist(
+3920            &self,
+3921            _: &str,
+3922            _: &[String],
+3923        ) -> Result<(), repo_types::RepoError> {
+3924            unimplemented!()
+3925        }
+3926        async fn move_playlist_item(
+3927            &self,
+3928            _: &str,
+3929            _: &str,
+3930            _: u32,
+3931        ) -> Result<(), repo_types::RepoError> {
+3932            unimplemented!()
+3933        }
+3934    }
+3935
+3936    /// Video (HTML5/Linux) path: ending mid-season must produce the
+3937    /// next-episode popup with auto-advance.
+3938    #[tokio::test]
+3939    async fn test_video_playback_ended_offers_next_episode() {
+3940        let controller = PlayerController::default();
+3941        let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
+3942
+3943        let decision = controller
+3944            .on_video_playback_ended("ep2", repo)
+3945            .await
+3946            .expect("decision should succeed");
+3947
+3948        match decision {
+3949            AutoplayDecision::ShowNextEpisodePopup {
+3950                current_episode,
+3951                next_episode,
+3952                auto_advance,
+3953                ..
+3954            } => {
+3955                assert_eq!(current_episode.id, "ep2");
+3956                assert_eq!(next_episode.id, "ep3");
+3957                assert!(auto_advance, "default settings should auto-advance");
+3958            }
+3959            other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
+3960        }
+3961    }
+3962
+3963    /// Last episode of the season: no popup, stop.
+3964    #[tokio::test]
+3965    async fn test_video_playback_ended_last_episode_stops() {
+3966        let controller = PlayerController::default();
+3967        let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
+3968
+3969        let decision = controller
+3970            .on_video_playback_ended("ep3", repo)
+3971            .await
+3972            .expect("decision should succeed");
+3973
+3974        assert!(matches!(decision, AutoplayDecision::Stop));
+3975    }
+3976
+3977    /// Android/ExoPlayer path: `on_playback_ended` has no per-call repository,
+3978    /// so the controller-level repository (wired up in `repository_create`)
+3979    /// must be used for the next-episode lookup. Regression test for episode
+3980    /// autoplay never triggering on Android because no repository was set.
+3981    #[tokio::test]
+3982    async fn test_playback_ended_uses_controller_repository_for_episodes() {
+3983        let controller = PlayerController::default();
+3984        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+3985
+3986        // Queue holds the episode that just finished playing
+3987        let episode = MediaItem {
+3988            // Audio and direct-URL items never negotiate a transport.
+3989            transport: None,
+3990            media_type: MediaType::Video,
+3991            source: MediaSource::Remote {
+3992                stream_url: "http://example.com/ep1.mkv".to_string(),
+3993                jellyfin_item_id: "ep1".to_string(),
+3994            },
+3995            ..create_test_items(1).remove(0)
+3996        };
+3997        controller.play_queue(vec![episode], 0).unwrap();
+3998
+3999        // Clear the NewTrackLoaded reason to simulate natural track end
+4000        controller.take_end_reason();
+4001
+4002        let decision = controller.on_playback_ended().await.unwrap();
+4003
+4004        match decision {
+4005            AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
+4006                assert_eq!(next_episode.id, "ep2");
+4007            }
+4008            other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
+4009        }
+4010    }
+4011
+4012    /// Background audio-only mode (UR-040): a video episode is handed off to the
+4013    /// native ExoPlayer *audio* path as a `MediaType::Audio` item so it keeps
+4014    /// playing while the app is backgrounded. When that audio track ends, autoplay
+4015    /// must STILL recognise it as an episode and offer the next one — otherwise
+4016    /// playback just pauses at the episode boundary (the reported bug). The item
+4017    /// carries its episode identity via `item_type: "Episode"` + `series_id`.
+4018    #[tokio::test]
+4019    async fn test_playback_ended_background_audio_episode_advances() {
+4020        let controller = PlayerController::default();
+4021        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4022
+4023        // Mirrors what player_enter_background_audio builds: the episode as AUDIO.
+4024        let episode = MediaItem {
+4025            // Audio and direct-URL items never negotiate a transport.
+4026            transport: None,
+4027            item_type: Some("Episode".to_string()),
+4028            media_type: MediaType::Audio, // audio-only handoff, not Video
+4029            series_id: Some("series1".to_string()),
+4030            duration: Some(180.0),
+4031            source: MediaSource::Remote {
+4032                stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
+4033                jellyfin_item_id: "ep2".to_string(),
+4034            },
+4035            ..create_test_items(1).remove(0)
+4036        };
+4037        controller.play_queue(vec![episode], 0).unwrap();
+4038
+4039        // Played through to the end — a natural finish, not a stream cut short.
+4040        controller.seek(180.0).unwrap();
+4041        // Clear the NewTrackLoaded reason to simulate natural track end.
+4042        controller.take_end_reason();
+4043
+4044        let decision = controller.on_playback_ended().await.unwrap();
+4045
+4046        match decision {
+4047            AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
+4048                assert_eq!(next_episode.id, "ep3");
+4049            }
+4050            other => panic!(
+4051                "background-audio episode end must advance to the next episode, got {:?}",
+4052                other
+4053            ),
+4054        }
+4055    }
+4056
+4057    /// The backend-driven advance (used when backgrounded) must load the next
+4058    /// episode as an AUDIO item carrying its episode identity, so the *following*
+4059    /// end-of-track also advances rather than stopping.
+4060    #[tokio::test]
+4061    async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
+4062        let controller = PlayerController::default();
+4063        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4064
+4065        controller
+4066            .advance_to_next_episode_audio_only("ep2")
+4067            .await
+4068            .expect("advance should succeed");
+4069
+4070        let current = controller
+4071            .queue
+4072            .lock_safe()
+4073            .current()
+4074            .cloned()
+4075            .expect("an item should be loaded");
+4076        assert_eq!(current.id, "ep2");
+4077        assert_eq!(current.media_type, MediaType::Audio);
+4078        assert_eq!(current.item_type.as_deref(), Some("Episode"));
+4079        assert_eq!(current.series_id.as_deref(), Some("series1"));
+4080        // Uses the audio-only URL, not a video stream.
+4081        match &current.source {
+4082            MediaSource::Remote { stream_url, .. } => {
+4083                assert!(
+4084                    stream_url.contains("audio"),
+4085                    "expected audio-only URL, got {}",
+4086                    stream_url
+4087                );
+4088            }
+4089            other => panic!("expected Remote source, got {:?}", other),
+4090        }
+4091
+4092        // The controller now considers itself mid background-audio episode, so the
+4093        // next end-of-track will advance again rather than stop.
+4094        assert!(controller.current_is_audio_episode());
+4095    }
+4096
+4097    /// The handoff base offset describes ONE stream: the audio-only URL built
+4098    /// with `StartTimeTicks` = the position the video was handed off at, whose
+4099    /// timeline therefore starts at that point. The next episode is loaded from
+4100    /// its own beginning, so its timeline is already absolute and the base must
+4101    /// be cleared — otherwise returning to the foreground resolves the resume
+4102    /// position as `old_base + position_in_new_episode` and the video jumps to a
+4103    /// point that has nothing to do with what was playing.
+4104    #[tokio::test]
+4105    async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
+4106        let controller = PlayerController::default();
+4107        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4108
+4109        // Handed off 20 minutes into the previous episode.
+4110        controller.set_background_audio_base(1200.0);
+4111
+4112        controller
+4113            .advance_to_next_episode_audio_only("ep2")
+4114            .await
+4115            .expect("advance should succeed");
+4116
+4117        assert_eq!(
+4118            controller.take_background_audio_base(),
+4119            0.0,
+4120            "the next episode starts at its own zero, so the previous handoff \
+4121             base must not survive the advance"
+4122        );
+4123    }
+4124
+4125    /// A background audio-only episode must advance IN THE BACKEND when the
+4126    /// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
+4127    /// countdown the frontend is supposed to act on.
+4128    ///
+4129    /// The countdown only emits CountdownTick events; the actual advance is a
+4130    /// `goto('/player/<id>')` in the webview. While the app is backgrounded that
+4131    /// navigation cannot start audio, so playback stalls at the episode boundary
+4132    /// with ExoPlayer parked in STATE_ENDED — and any later play intent
+4133    /// (lockscreen, headset, Bluetooth reconnect) replays the ended item from the
+4134    /// start, which is what surfaces to the user as "the episode randomly
+4135    /// restarted".
+4136    #[tokio::test]
+4137    async fn test_auto_advance_background_audio_episode_advances_in_backend() {
+4138        let controller = PlayerController::default();
+4139        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4140
+4141        // Currently playing: ep2 handed off to audio-only background playback.
+4142        let episode = MediaItem {
+4143            // Audio and direct-URL items never negotiate a transport.
+4144            transport: None,
+4145            id: "ep2".to_string(),
+4146            item_type: Some("Episode".to_string()),
+4147            media_type: MediaType::Audio,
+4148            series_id: Some("series1".to_string()),
+4149            source: MediaSource::Remote {
+4150                stream_url: "http://example.com/ep2-audio.mp3".to_string(),
+4151                jellyfin_item_id: "ep2".to_string(),
+4152            },
+4153            ..create_test_items(1).remove(0)
+4154        };
+4155        controller.play_queue(vec![episode], 0).unwrap();
+4156
+4157        let next = make_repo_episode("ep3", 3);
+4158        controller.auto_advance_to_next_episode(next, 10).await;
+4159
+4160        let current = controller
+4161            .queue
+4162            .lock_safe()
+4163            .current()
+4164            .cloned()
+4165            .expect("an item should still be loaded");
+4166        assert_eq!(
+4167            current.id, "ep3",
+4168            "background audio-only episode must advance in the backend, not wait \
+4169             for a frontend navigation that cannot happen while backgrounded"
+4170        );
+4171        assert_eq!(current.media_type, MediaType::Audio);
+4172        assert!(controller.current_is_audio_episode());
+4173    }
+4174
+4175    /// Build the audio-only episode the background handoff loads: a video item
+4176    /// played through the native audio path, with a known runtime and a stream
+4177    /// URL carrying the handoff position.
+4178    fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
+4179        MediaItem {
+4180            // Audio and direct-URL items never negotiate a transport.
+4181            transport: None,
+4182            id: "ep2".to_string(),
+4183            item_type: Some("Episode".to_string()),
+4184            media_type: MediaType::Audio,
+4185            series_id: Some("series1".to_string()),
+4186            duration: Some(runtime_seconds),
+4187            source: MediaSource::Remote {
+4188                stream_url:
+4189                    "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
+4190                        .to_string(),
+4191                jellyfin_item_id: "ep2".to_string(),
+4192            },
+4193            ..create_test_items(1).remove(0)
+4194        }
+4195    }
+4196
+4197    /// The same episode handed off from a **downloaded file** — the handoff's
+4198    /// other source, which starts at the episode's own zero rather than at the
+4199    /// handoff point.
+4200    fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
+4201        MediaItem {
+4202            // Audio and direct-URL items never negotiate a transport.
+4203            transport: None,
+4204            source: MediaSource::Local {
+4205                file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
+4206                jellyfin_item_id: Some("ep2".to_string()),
+4207            },
+4208            ..audio_only_episode(runtime_seconds)
+4209        }
+4210    }
+4211
+4212    /// A file seeks like a file. The rebuild path exists because a chunked
+4213    /// length-less transcode cannot honour a seek, which is not true of local
+4214    /// media — and `resume_stream_at` refuses a non-remote source outright, so
+4215    /// routing a lockscreen scrub through it fails the seek instead of doing it.
+4216    ///
+4217    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
+4218    #[tokio::test]
+4219    async fn test_seek_absolute_on_a_downloaded_handoff_is_an_ordinary_seek() {
+4220        let controller = PlayerController::default();
+4221        controller
+4222            .play_queue(vec![local_audio_only_episode(1500.0)], 0)
+4223            .unwrap();
+4224        // A downloaded handoff claims no base: the file's zero is the episode's.
+4225        controller.enter_background_audio(0.0);
+4226
+4227        controller.seek_absolute(900.0).await.unwrap();
+4228
+4229        assert_eq!(controller.position(), 900.0);
+4230    }
+4231
+4232    /// A flaky connection truncates the progressive mp3 transcode that carries
+4233    /// background audio-only playback. ExoPlayer sees end-of-input on a stream
+4234    /// with no reliable length, so it reports STATE_ENDED ten minutes into a
+4235    /// twenty-five minute episode — indistinguishable, to the player, from the
+4236    /// real end.
+4237    ///
+4238    /// Treating that as "the episode finished" is what the user experiences as
+4239    /// the episode randomly restarting: playback parks in STATE_ENDED and the
+4240    /// next play intent (lockscreen, notification, Bluetooth reconnect) seeks an
+4241    /// ended player to position 0 before playing. The runtime we already know
+4242    /// says the stream died early, so the decision must be to resume it.
+4243    #[tokio::test]
+4244    async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
+4245        let controller = PlayerController::default();
+4246        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4247
+4248        controller
+4249            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4250            .unwrap();
+4251        // The connection dropped 10 minutes into a 25-minute episode.
+4252        controller.seek(600.0).unwrap();
+4253        controller.take_end_reason();
+4254
+4255        let decision = controller.on_playback_ended().await.unwrap();
+4256
+4257        match decision {
+4258            AutoplayDecision::ResumeStream { position } => {
+4259                assert_eq!(position, 600.0, "must resume where the stream died");
+4260            }
+4261            other => panic!(
+4262                "a stream that ended 15 minutes short of the runtime must resume, \
+4263                 not run end-of-episode logic; got {:?}",
+4264                other
+4265            ),
+4266        }
+4267    }
+4268
+4269    /// A seek arriving during a background-audio handoff is **absolute** — the
+4270    /// lockscreen scrubber shows the whole episode, so a scrub to 25:00 means
+4271    /// 25:00 of the episode, not 25:00 into the handoff stream.
+4272    ///
+4273    /// The handoff stream cannot be seeked at all (a chunked, length-less
+4274    /// transcode), so honouring it means re-opening the URL at the new position,
+4275    /// exactly as the truncation recovery does. Passing the number through to
+4276    /// ExoPlayer instead — which is what used to happen — asked a stream that
+4277    /// cannot seek to jump past its own end, and a clamped seek lands at stream
+4278    /// zero: the handoff point.
+4279    ///
+4280    /// TRACES: UR-040, UR-005 | DR-159 | UT-155
+4281    #[tokio::test]
+4282    async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
+4283        let controller = PlayerController::default();
+4284        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4285        controller
+4286            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4287            .unwrap();
+4288
+4289        // Handed off 20 minutes in, so the stream's zero is 1200s.
+4290        controller.enter_background_audio(1200.0);
+4291
+4292        // The viewer scrubs the lockscreen to 25:00 absolute.
+4293        controller.seek_absolute(1490.0).await.unwrap();
+4294
+4295        let url = {
+4296            let queue = controller.queue();
+4297            let queue = queue.lock_safe();
+4298            match &queue.current().unwrap().source {
+4299                MediaSource::Remote { stream_url, .. } => stream_url.clone(),
+4300                other => panic!("expected a remote source, got {:?}", other),
+4301            }
+4302        };
+4303        assert!(
+4304            url.contains(&format!(
+4305                "StartTimeTicks={}",
+4306                (1490.0 * 10_000_000.0) as i64
+4307            )),
+4308            "the stream must be re-opened at the absolute position; got {}",
+4309            url
+4310        );
+4311
+4312        assert_eq!(
+4313            *controller.background_audio_base.lock_safe(),
+4314            1490.0,
+4315            "the re-opened stream's zero is the position it was opened at, or \
+4316             every later reading is off by the difference"
+4317        );
+4318    }
+4319
+4320    /// Outside a handoff there is no base and nothing to re-open: an absolute
+4321    /// seek is just a seek, and must not be turned into a stream rebuild.
+4322    ///
+4323    /// TRACES: UR-005 | DR-159 | UT-155
+4324    #[tokio::test]
+4325    async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
+4326        let controller = PlayerController::default();
+4327        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4328        controller
+4329            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4330            .unwrap();
+4331
+4332        controller.seek_absolute(300.0).await.unwrap();
+4333
+4334        assert_eq!(controller.position(), 300.0);
+4335        assert_eq!(
+4336            *controller.background_audio_base.lock_safe(),
+4337            0.0,
+4338            "an ordinary seek must not invent a handoff base"
+4339        );
+4340    }
+4341
+4342    // ===== Position authority and reporting (DR-178, DR-179) =====
+4343    //
+4344    // Every position that leaves the app — the resume point Jellyfin stores, the
+4345    // point the video reloads at on the way back from a handoff, the truncation
+4346    // maths — is read off the controller. The device trace showed all of them
+4347    // reading 0: the native backend is not the player on the webview path, and
+4348    // during a handoff its base is only applied once ExoPlayer has ticked, which
+4349    // it has not while the audio-only transcode is still opening.
+4350
+4351    /// Returning to the foreground before the audio-only stream has started
+4352    /// playing hands back the handoff's own starting point, never zero.
+4353    ///
+4354    /// Observed on device: locked at 18.4s, unlocked 3.5s later with ExoPlayer
+4355    /// still `IDLE`, `player_exit_background_audio` returned `0.0`, and the video
+4356    /// reloaded with `StartTimeTicks=0` — the episode restarted from the
+4357    /// beginning, and the `Stopped` report that followed wiped the server's
+4358    /// resume point too.
+4359    ///
+4360    /// TRACES: UR-040 | DR-178 | UT-176
+4361    #[test]
+4362    fn test_absolute_position_floors_at_the_handoff_base() {
+4363        let controller = PlayerController::default();
+4364        controller.enter_background_audio(18.4);
+4365
+4366        // No tick has landed, so nothing has applied the base yet.
+4367        assert_eq!(controller.position(), 0.0);
+4368        assert_eq!(
+4369            controller.absolute_position(),
+4370            18.4,
+4371            "the audio stream's zero IS the handoff point, so the position can \
+4372             never legitimately read below it"
+4373        );
+4374    }
+4375
+4376    /// Once ticks are flowing the base has already been applied at the native
+4377    /// boundary (DR-159), so flooring must not add it a second time.
+4378    ///
+4379    /// TRACES: UR-040 | DR-178 | UT-176
+4380    #[test]
+4381    fn test_absolute_position_does_not_double_count_the_handoff_base() {
+4382        let controller = PlayerController::default();
+4383        controller.enter_background_audio(18.4);
+4384
+4385        // What the real backend reports after a tick: already absolute.
+4386        controller.seek(120.0).unwrap();
+4387
+4388        assert_eq!(controller.absolute_position(), 120.0);
+4389    }
+4390
+4391    /// On the webview path the `<video>` element is the player and the native
+4392    /// backend holds nothing, so the position it reports is the only one there
+4393    /// is. It used to be re-emitted to the frontend and then dropped, leaving
+4394    /// every backend-side report at 0.
+4395    ///
+4396    /// TRACES: UR-005, UR-025 | DR-178 | UT-177
+4397    #[test]
+4398    fn test_webview_position_reports_become_the_controllers_position() {
+4399        let controller = PlayerController::default();
+4400
+4401        controller.report_html5_position(253.4, 2640.0);
+4402
+4403        assert_eq!(controller.absolute_position(), 253.4);
+4404        assert_eq!(controller.observed_duration(), Some(2640.0));
+4405    }
+4406
+4407    /// A torn-down element's last position must not outlive it: the next thing
+4408    /// to play is loaded into the native backend, and a stale 253s would be
+4409    /// reported against it.
+4410    ///
+4411    /// TRACES: UR-005 | DR-178 | UT-177
+4412    #[test]
+4413    fn test_webview_teardown_clears_the_observed_position() {
+4414        let controller = PlayerController::default();
+4415        controller.report_html5_position(253.4, 2640.0);
+4416
+4417        controller.report_html5_state("stopped".to_string(), None);
+4418
+4419        assert_eq!(controller.absolute_position(), 0.0);
+4420    }
+4421
+4422    /// Entering a handoff tears the element down, so its position stops being
+4423    /// the answer at that exact moment — the native audio player's does.
+4424    ///
+4425    /// TRACES: UR-040 | DR-178 | UT-177
+4426    #[test]
+4427    fn test_entering_a_handoff_drops_the_torn_down_elements_position() {
+4428        let controller = PlayerController::default();
+4429        controller.report_html5_position(253.4, 2640.0);
+4430
+4431        controller.enter_background_audio(18.4);
+4432
+4433        assert_eq!(
+4434            controller.absolute_position(),
+4435            18.4,
+4436            "the video element is gone; only the handoff base describes the \
+4437             stream that is now playing"
+4438        );
+4439    }
+4440
+4441    /// Nobody ever watched zero seconds of anything. A `Stopped` at 0 carries no
+4442    /// information and Jellyfin stores it as the resume point, so the only thing
+4443    /// it can do is destroy one — which is what the device trace caught it doing
+4444    /// 14 times in 35 minutes, including 40s after the frontend had correctly
+4445    /// reported 922s for the same episode.
+4446    ///
+4447    /// TRACES: UR-025 | DR-179 | UT-178
+4448    #[tokio::test]
+4449    async fn test_a_stop_at_zero_is_never_reported() {
+4450        let controller = PlayerController::default();
+4451        let reports = Arc::new(CapturingReports::new());
+4452        controller.set_report_sink(reports.clone());
+4453        controller
+4454            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4455            .unwrap();
+4456
+4457        // Nothing ever played: the backend is at 0 and no element reported in.
+4458        controller.stop().unwrap();
+4459
+4460        assert!(
+4461            reports.stops().is_empty(),
+4462            "a zero-position stop must be withheld, not sent; got {:?}",
+4463            reports.stops()
+4464        );
+4465    }
+4466
+4467    /// A real position is still reported, so withholding zero cannot be
+4468    /// mistaken for withholding everything — from either rendering path.
+4469    ///
+4470    /// The webview half is the one that was broken: the element reports 253s, the
+4471    /// native backend holds nothing, and the stop report went out as 0 and
+4472    /// overwrote the resume point the frontend had just written correctly.
+4473    ///
+4474    /// TRACES: UR-025 | DR-178, DR-179 | UT-178
+4475    #[tokio::test]
+4476    async fn test_a_stop_reports_the_position_actually_reached() {
+4477        // Webview-rendered: the element is the only thing that knows.
+4478        let webview = PlayerController::default();
+4479        let webview_reports = Arc::new(CapturingReports::new());
+4480        webview.set_report_sink(webview_reports.clone());
+4481        webview
+4482            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4483            .unwrap();
+4484        webview.report_html5_position(253.0, 1500.0);
+4485
+4486        webview.stop().unwrap();
+4487
+4488        assert_eq!(webview_reports.stops(), vec![("ep2".to_string(), 253.0)]);
+4489
+4490        // Natively rendered: the backend is authoritative and still is.
+4491        let native = PlayerController::default();
+4492        let native_reports = Arc::new(CapturingReports::new());
+4493        native.set_report_sink(native_reports.clone());
+4494        native
+4495            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4496            .unwrap();
+4497        native.seek(253.0).unwrap();
+4498
+4499        native.stop().unwrap();
+4500
+4501        assert_eq!(native_reports.stops(), vec![("ep2".to_string(), 253.0)]);
+4502    }
+4503
+4504    /// An episode listened to end-to-end on the lockscreen must count as
+4505    /// watched. Jellyfin decides that on the `PlaybackStopped` report — no
+4506    /// report, no completion — and in background audio-only mode there is
+4507    /// nobody else to send one: the webview is suspended and its `<video>` was
+4508    /// torn down at the handoff, so the frontend's end-of-playback reporting
+4509    /// cannot run. The backend advanced to the next episode and said nothing
+4510    /// about the one that finished.
+4511    ///
+4512    /// TRACES: UR-040, UR-025 | DR-179 | UT-179
+4513    #[tokio::test]
+4514    async fn test_a_finished_audio_only_episode_is_reported_complete() {
+4515        let controller = PlayerController::default();
+4516        let reports = Arc::new(CapturingReports::new());
+4517        controller.set_report_sink(reports.clone());
+4518        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4519        controller
+4520            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4521            .unwrap();
+4522        // Played out to the end of the 25-minute episode.
+4523        controller.seek(1499.0).unwrap();
+4524        controller.take_end_reason();
+4525
+4526        controller.on_playback_ended().await.unwrap();
+4527
+4528        assert_eq!(
+4529            reports.stops(),
+4530            vec![("ep2".to_string(), 1500.0)],
+4531            "the finished episode must be reported stopped at its runtime, or \
+4532             Jellyfin's ≥90% rule never marks it played"
+4533        );
+4534    }
+4535
+4536    /// The completion report is for ends that are really ends. A truncated
+4537    /// stream is about to be re-opened and the episode is nowhere near over, so
+4538    /// reporting it stopped would tell Jellyfin the opposite of the truth.
+4539    ///
+4540    /// TRACES: UR-040, UR-025 | DR-179 | UT-179
+4541    #[tokio::test]
+4542    async fn test_a_truncated_stream_reports_no_completion() {
+4543        let controller = PlayerController::default();
+4544        let reports = Arc::new(CapturingReports::new());
+4545        controller.set_report_sink(reports.clone());
+4546        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4547        controller
+4548            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4549            .unwrap();
+4550        controller.seek(600.0).unwrap();
+4551        controller.take_end_reason();
+4552
+4553        let decision = controller.on_playback_ended().await.unwrap();
+4554
+4555        assert!(matches!(decision, AutoplayDecision::ResumeStream { .. }));
+4556        assert!(
+4557            reports.stops().is_empty(),
+4558            "a dropped connection is not a finished episode; got {:?}",
+4559            reports.stops()
+4560        );
+4561    }
+4562
+4563    /// Position ticks reach Jellyfin while playback is still going, so closing
+4564    /// the app — or losing it to a crash — cannot cost the whole session. The
+4565    /// device trace requested `/Sessions/Playing/Progress` exactly zero times in
+4566    /// 35 minutes: the frontend service writes progress to the local DB only,
+4567    /// and nothing on the Rust side reported it for webview-rendered media.
+4568    ///
+4569    /// TRACES: UR-005, UR-025 | DR-179 | UT-180
+4570    #[tokio::test]
+4571    async fn test_webview_position_ticks_report_progress_to_the_server() {
+4572        let controller = PlayerController::default();
+4573        let reports = Arc::new(CapturingReports::new());
+4574        controller.set_report_sink(reports.clone());
+4575        controller
+4576            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4577            .unwrap();
+4578
+4579        controller.report_html5_position(253.4, 1500.0);
+4580
+4581        assert_eq!(reports.progress(), vec![("ep2".to_string(), 253.4)]);
+4582    }
+4583
+4584    /// Ticks arrive four times a second; reports must not. The throttler the
+4585    /// controller already owns bounds them to one per item per 30s.
+4586    ///
+4587    /// TRACES: UR-005 | DR-179 | UT-180
+4588    #[tokio::test]
+4589    async fn test_progress_reports_are_throttled_not_sent_per_tick() {
+4590        let controller = PlayerController::default();
+4591        let reports = Arc::new(CapturingReports::new());
+4592        controller.set_report_sink(reports.clone());
+4593        controller
+4594            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4595            .unwrap();
+4596
+4597        for tick in 0..12 {
+4598            controller.report_html5_position(250.0 + tick as f64 * 0.25, 1500.0);
+4599        }
+4600
+4601        assert_eq!(
+4602            reports.progress().len(),
+4603            1,
+4604            "twelve ticks inside one throttle window are one report"
+4605        );
+4606    }
+4607
+4608    /// The truncation check compares the position against the item's runtime, so
+4609    /// both must be on the same timeline.
+4610    ///
+4611    /// They now are by construction: the Android position tick shifts by the
+4612    /// handoff base before anything sees the value, so what the player reports is
+4613    /// already a position on the episode. The base is therefore *not* added here —
+4614    /// doing so would double-count it and make the last minute of a handoff look
+4615    /// like a truncation. What the mock backend holds is what the real one would
+4616    /// report: 24:56 absolute, not 0:56 into the handoff stream. (DR-159)
+4617    #[tokio::test]
+4618    async fn test_truncated_check_uses_the_absolute_position() {
+4619        let controller = PlayerController::default();
+4620        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4621
+4622        controller
+4623            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4624            .unwrap();
+4625        // Handed off at 24:00; the stream then played its last 56 seconds out, so
+4626        // the player reports 24:56 of the episode.
+4627        controller.set_background_audio_base(1440.0);
+4628        controller.seek(1496.0).unwrap();
+4629        controller.take_end_reason();
+4630
+4631        let decision = controller.on_playback_ended().await.unwrap();
+4632
+4633        assert!(
+4634            matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
+4635            "24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
+4636            decision
+4637        );
+4638    }
+4639
+4640    /// The resume re-opens the same URL, so a server that is actually gone would
+4641    /// otherwise end → resume → end forever. After the budget runs out the
+4642    /// decision falls back to normal end-of-item handling.
+4643    #[tokio::test]
+4644    async fn test_repeated_truncation_at_the_same_position_gives_up() {
+4645        let controller = PlayerController::default();
+4646        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4647
+4648        controller
+4649            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4650            .unwrap();
+4651        controller.seek(600.0).unwrap();
+4652
+4653        for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
+4654            controller.take_end_reason();
+4655            let decision = controller.on_playback_ended().await.unwrap();
+4656            assert!(
+4657                matches!(decision, AutoplayDecision::ResumeStream { .. }),
+4658                "attempt {} should still resume, got {:?}",
+4659                attempt,
+4660                decision
+4661            );
+4662        }
+4663
+4664        controller.take_end_reason();
+4665        let decision = controller.on_playback_ended().await.unwrap();
+4666        assert!(
+4667            !matches!(decision, AutoplayDecision::ResumeStream { .. }),
+4668            "a stream stuck at the same position must stop retrying, got {:?}",
+4669            decision
+4670        );
+4671    }
+4672
+4673    /// Ordinary music is not covered: its streams are not the length-less
+4674    /// progressive transcode this guards, and a short track legitimately ends
+4675    /// well before a stale duration would suggest.
+4676    #[tokio::test]
+4677    async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
+4678        let controller = PlayerController::default();
+4679
+4680        let mut items = create_test_items(2);
+4681        items[0].duration = Some(1500.0);
+4682        controller.play_queue(items, 0).unwrap();
+4683        controller.seek(60.0).unwrap();
+4684        controller.take_end_reason();
+4685
+4686        let decision = controller.on_playback_ended().await.unwrap();
+4687        assert!(
+4688            matches!(decision, AutoplayDecision::AdvanceToNext),
+4689            "plain queue audio must keep advancing, got {:?}",
+4690            decision
+4691        );
+4692    }
+4693
+4694    /// Music and video stream from URLs that declare their own length (a static
+4695    /// file with byte ranges, an HLS playlist), so a truncation reaches the
+4696    /// player as an *error* rather than a phantom end. It is the same network
+4697    /// failure, and the same recovery applies — the previous behaviour turned it
+4698    /// into `playerStop()` and silence.
+4699    #[tokio::test]
+4700    async fn test_recoverable_error_resumes_a_music_track() {
+4701        let controller = PlayerController::default();
+4702
+4703        let mut items = create_test_items(3);
+4704        for item in &mut items {
+4705            item.source = MediaSource::Remote {
+4706                stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
+4707                jellyfin_item_id: item.id.clone(),
+4708            };
+4709        }
+4710        controller.play_queue(items, 1).unwrap();
+4711        controller.seek(45.0).unwrap();
+4712
+4713        let (position, _) = controller
+4714            .recoverable_error_resume()
+4715            .expect("a streamed music track must be resumable after a network error");
+4716        assert_eq!(position, 45.0);
+4717    }
+4718
+4719    /// The resume must reload the failed track IN PLACE. `play_item` replaces the
+4720    /// whole queue with a single item, so recovering a track that way would throw
+4721    /// away the rest of the album — turning a network blip into lost state.
+4722    #[tokio::test]
+4723    async fn test_resume_keeps_the_rest_of_the_queue() {
+4724        let controller = PlayerController::default();
+4725
+4726        let mut items = create_test_items(3);
+4727        for item in &mut items {
+4728            item.source = MediaSource::Remote {
+4729                stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
+4730                jellyfin_item_id: item.id.clone(),
+4731            };
+4732        }
+4733        controller.play_queue(items, 1).unwrap();
+4734
+4735        controller
+4736            .resume_stream_at(45.0)
+4737            .await
+4738            .expect("resume should succeed");
+4739
+4740        let queue = controller.queue.lock_safe();
+4741        assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
+4742        assert_eq!(queue.current_index(), Some(1), "still on the same track");
+4743        assert_eq!(queue.current().unwrap().id, "item_1");
+4744    }
+4745
+4746    /// A seekable stream is re-opened by re-preparing the URL it already has and
+4747    /// seeking — its timeline is intact, and rewriting the URL would restart a
+4748    /// transcode session for no reason.
+4749    #[tokio::test]
+4750    async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
+4751        let controller = PlayerController::default();
+4752
+4753        let mut items = create_test_items(1);
+4754        items[0].source = MediaSource::Remote {
+4755            stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
+4756            jellyfin_item_id: "item_0".to_string(),
+4757        };
+4758        controller.play_queue(items, 0).unwrap();
+4759
+4760        controller.resume_stream_at(45.0).await.unwrap();
+4761
+4762        match &controller.queue.lock_safe().current().unwrap().source {
+4763            MediaSource::Remote { stream_url, .. } => {
+4764                assert_eq!(
+4765                    stream_url, "http://s/Audio/item_0/stream?Static=true",
+4766                    "a seekable stream's URL must be left alone"
+4767                );
+4768            }
+4769            other => panic!("expected Remote source, got {:?}", other),
+4770        }
+4771        assert_eq!(
+4772            controller.position(),
+4773            45.0,
+4774            "and it must land at the position"
+4775        );
+4776    }
+4777
+4778    /// Downloaded media cannot fail from the network, and re-opening a local file
+4779    /// would paper over a real read error.
+4780    #[tokio::test]
+4781    async fn test_recoverable_error_ignores_local_media() {
+4782        let controller = PlayerController::default();
+4783
+4784        let mut items = create_test_items(1);
+4785        items[0].source = MediaSource::Local {
+4786            file_path: "/music/track.flac".into(),
+4787            jellyfin_item_id: Some("item_0".to_string()),
+4788        };
+4789        controller.play_queue(items, 0).unwrap();
+4790        controller.seek(45.0).unwrap();
+4791
+4792        assert!(controller.recoverable_error_resume().is_none());
+4793    }
+4794
+4795    /// A recoverable error during background audio-only playback is the network,
+4796    /// not the media — the previous behaviour (surface it, frontend stops the
+4797    /// player) turned a hiccup into silence. Retrying must also back off, or the
+4798    /// three attempts are spent inside a second and the outage outlives them.
+4799    #[tokio::test]
+4800    async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
+4801        let controller = PlayerController::default();
+4802
+4803        controller
+4804            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4805            .unwrap();
+4806        controller.seek(600.0).unwrap();
+4807
+4808        let mut waits = Vec::new();
+4809        for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
+4810            let (position, delay) = controller
+4811                .recoverable_error_resume()
+4812                .unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
+4813            assert_eq!(position, 600.0);
+4814            waits.push(delay);
+4815        }
+4816        assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
+4817        assert!(
+4818            controller.recoverable_error_resume().is_none(),
+4819            "a stream that keeps failing at the same spot must surface the error"
+4820        );
+4821    }
+4822
+4823    /// Plugin/channel `DirectUrl` sources are somebody else's endpoint with no
+4824    /// Jellyfin item behind them, so the resume has nothing to re-request.
+4825    #[tokio::test]
+4826    async fn test_recoverable_error_ignores_direct_url_playback() {
+4827        let controller = PlayerController::default();
+4828        controller.play_queue(create_test_items(2), 0).unwrap();
+4829
+4830        assert!(controller.recoverable_error_resume().is_none());
+4831    }
+4832
+4833    /// Re-opening the stream must land where it died and keep playing, with the
+4834    /// handoff base moved to the new stream's zero so returning to the
+4835    /// foreground still resolves an absolute position.
+4836    #[tokio::test]
+4837    async fn test_resume_truncated_stream_reloads_at_position() {
+4838        let controller = PlayerController::default();
+4839
+4840        controller
+4841            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4842            .unwrap();
+4843        controller.set_background_audio_base(0.0);
+4844
+4845        controller
+4846            .resume_stream_at(600.0)
+4847            .await
+4848            .expect("resume should succeed");
+4849
+4850        let current = controller
+4851            .queue
+4852            .lock_safe()
+4853            .current()
+4854            .cloned()
+4855            .expect("the same item should still be loaded");
+4856        assert_eq!(current.id, "ep2", "resume must not change the item");
+4857        match &current.source {
+4858            MediaSource::Remote { stream_url, .. } => {
+4859                assert!(
+4860                    stream_url.contains("StartTimeTicks=6000000000"),
+4861                    "stream must re-open at 600s, got {}",
+4862                    stream_url
+4863                );
+4864                assert!(
+4865                    stream_url.contains("AudioStreamIndex=2"),
+4866                    "the selected audio track must survive the resume, got {}",
+4867                    stream_url
+4868                );
+4869            }
+4870            other => panic!("expected Remote source, got {:?}", other),
+4871        }
+4872        assert_eq!(
+4873            controller.take_background_audio_base(),
+4874            600.0,
+4875            "the re-opened stream's zero is the resume position"
+4876        );
+4877    }
+4878
+4879    /// Foreground video playback keeps the countdown-driven advance: the frontend
+4880    /// owns the navigation there, so the backend must NOT load the next episode
+4881    /// itself (that would race the page transition and double-start playback).
+4882    #[tokio::test]
+4883    async fn test_auto_advance_foreground_video_episode_uses_countdown() {
+4884        let controller = PlayerController::default();
+4885        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4886
+4887        let episode = MediaItem {
+4888            // Audio and direct-URL items never negotiate a transport.
+4889            transport: None,
+4890            id: "ep2".to_string(),
+4891            item_type: Some("Episode".to_string()),
+4892            media_type: MediaType::Video,
+4893            series_id: Some("series1".to_string()),
+4894            source: MediaSource::Remote {
+4895                stream_url: "http://example.com/ep2.m3u8".to_string(),
+4896                jellyfin_item_id: "ep2".to_string(),
+4897            },
+4898            ..create_test_items(1).remove(0)
+4899        };
+4900        controller.play_queue(vec![episode], 0).unwrap();
+4901
+4902        let next = make_repo_episode("ep3", 3);
+4903        controller.auto_advance_to_next_episode(next, 10).await;
+4904
+4905        let current = controller
+4906            .queue
+4907            .lock_safe()
+4908            .current()
+4909            .cloned()
+4910            .expect("an item should still be loaded");
+4911        assert_eq!(
+4912            current.id, "ep2",
+4913            "foreground video advance is frontend-driven; the backend must not \
+4914             swap the queue item out from under it"
+4915        );
+4916    }
+4917
+4918    /// Without a controller repository the Android episode path must still
+4919    /// stop gracefully (previous behavior) rather than error.
+4920    #[tokio::test]
+4921    async fn test_playback_ended_without_repository_stops() {
+4922        let controller = PlayerController::default();
+4923
+4924        let episode = MediaItem {
+4925            // Audio and direct-URL items never negotiate a transport.
+4926            transport: None,
+4927            media_type: MediaType::Video,
+4928            source: MediaSource::Remote {
+4929                stream_url: "http://example.com/ep1.mkv".to_string(),
+4930                jellyfin_item_id: "ep1".to_string(),
+4931            },
+4932            ..create_test_items(1).remove(0)
+4933        };
+4934        controller.play_queue(vec![episode], 0).unwrap();
+4935        controller.take_end_reason();
+4936
+4937        let decision = controller.on_playback_ended().await.unwrap();
+4938        assert!(matches!(decision, AutoplayDecision::Stop));
+4939    }
+4940}
+
\ 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..21372f77 --- /dev/null +++ b/api/src/jellytau_lib/player/mpv_backend.rs.html @@ -0,0 +1,989 @@ +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    /// A seek that arrived before MPV had a file to seek in.
+38    ///
+39    /// `loadfile` is asynchronous: it returns as soon as the command is queued,
+40    /// so `time-pos` is not yet a resolvable property and setting it fails. A
+41    /// seek issued in that window used to be dropped on the floor, and the two
+42    /// callers that do exactly this are the ones a viewer notices — resume, and
+43    /// a transcoded seek, both of which re-open the stream and then ask for a
+44    /// position. The stream reloaded and played from zero.
+45    ///
+46    /// Held here and applied by the `FileLoaded` arm.
+47    ///
+48    /// TRACES: UR-040, UR-005 | DR-241
+49    pending_seek: Arc<Mutex<Option<f64>>>,
+50}
+51
+52struct InternalState {
+53    current_media: Option<MediaItem>,
+54    volume: f32,
+55}
+56
+57/// Detect which audio system is available on the system
+58fn detect_audio_system() -> String {
+59    info!("[MpvBackend] Detecting audio system...");
+60
+61    // Try PulseAudio/PipeWire first (most common on modern Linux)
+62    if let Ok(output) = Command::new("pactl").arg("info").output() {
+63        if output.status.success() {
+64            let stdout = String::from_utf8_lossy(&output.stdout);
+65            if stdout.contains("PipeWire") {
+66                info!("[MpvBackend] Detected PipeWire (with PulseAudio compatibility)");
+67                return "pulse".to_string();
+68            } else if stdout.contains("PulseAudio") {
+69                info!("[MpvBackend] Detected PulseAudio");
+70                return "pulse".to_string();
+71            }
+72        }
+73    }
+74
+75    // Try detecting PipeWire directly
+76    if let Ok(output) = Command::new("pw-cli").arg("info").arg("0").output() {
+77        if output.status.success() {
+78            info!("[MpvBackend] Detected PipeWire");
+79            return "pulse".to_string(); // PipeWire works with pulse driver
+80        }
+81    }
+82
+83    // Check if ALSA is available
+84    if std::path::Path::new("/proc/asound/cards").exists() {
+85        info!("[MpvBackend] Falling back to ALSA");
+86        return "alsa".to_string();
+87    }
+88
+89    // Default fallback
+90    warn!("[MpvBackend] Could not detect audio system, using 'auto'");
+91    "auto".to_string()
+92}
+93
+94/// Helper to get stream URL from MediaItem
+95fn get_stream_url(media: &MediaItem) -> String {
+96    match &media.source {
+97        MediaSource::Remote { stream_url, .. } => stream_url.clone(),
+98        MediaSource::Local { file_path, .. } => {
+99            format!("file://{}", file_path.to_string_lossy())
+100        }
+101        MediaSource::DirectUrl { url } => url.clone(),
+102    }
+103}
+104
+105/// The mpv handle of the backend this process created, for the video surface.
+106///
+107/// A `OnceLock` rather than a field reached through `PlayerBackend`, because the
+108/// trait is cross-platform and a raw mpv pointer is not something every backend
+109/// should have to pretend to have. Stored as `usize` because a raw pointer is
+110/// neither `Send` nor `Sync`; the only consumer is the GTK main thread, which is
+111/// also where mpv was created.
+112///
+113/// Written once at construction and never cleared: the backend outlives the
+114/// window, so there is no window in which this could dangle while a surface is
+115/// still using it.
+116///
+117/// TRACES: UR-080 | DR-231
+118static MPV_HANDLE: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
+119
+120/// The registered handle, or null if no MPV backend was created (initialisation
+121/// can fail, and the app falls back to a no-op backend rather than dying).
+122///
+123/// TRACES: UR-080 | DR-231
+124pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
+125    MPV_HANDLE
+126        .get()
+127        .map(|p| *p as *mut libmpv_sys::mpv_handle)
+128        .unwrap_or(std::ptr::null_mut())
+129}
+130
+131impl MpvBackend {
+132    /// Create a new MPV backend
+133    pub fn new(
+134        event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
+135        playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
+136        position_throttler: Arc<EventThrottler>,
+137    ) -> Result<Self, PlayerError> {
+138        info!("[MpvBackend] Initializing MPV backend...");
+139
+140        // MPV requires LC_NUMERIC to be set to "C" locale
+141        // Set it before initializing MPV, then restore it after
+142        use std::ffi::CString;
+143        unsafe {
+144            let c_locale = CString::new("C").unwrap();
+145            libc::setlocale(libc::LC_NUMERIC, c_locale.as_ptr());
+146        }
+147
+148        let mpv = Mpv::new().map_err(|e| PlayerError {
+149            message: format!("Failed to initialize MPV: {:?}", e),
+150        })?;
+151
+152        // Detect and configure audio output
+153        let audio_driver = detect_audio_system();
+154        info!(
+155            "[MpvBackend] Configuring audio output driver: {}",
+156            audio_driver
+157        );
+158
+159        mpv.set_property("ao", audio_driver.as_str())
+160            .map_err(|e| PlayerError {
+161                message: format!(
+162                    "Failed to set audio output to '{}': {:?}. Make sure audio system is working.",
+163                    audio_driver, e
+164                ),
+165            })?;
+166
+167        // Enable verbose logging for audio initialization
+168        mpv.set_property("msg-level", "all=warn,ao=debug")
+169            .unwrap_or_else(|e| {
+170                warn!("[MpvBackend] Warning: Could not set MPV log level: {:?}", e);
+171            });
+172
+173        // Configure MPV for audio playback
+174        mpv.set_property("audio-display", "no")
+175            .map_err(|e| PlayerError {
+176                message: format!("Failed to configure MPV audio-display: {:?}", e),
+177            })?;
+178
+179        // Video is disabled unless this process is drawing it.
+180        //
+181        // `video: no` is why mpv has never decoded a frame here: Linux video has
+182        // always gone through the webview, and decoding it twice would burn a
+183        // core for a picture nobody sees. With native video on, mpv needs both
+184        // the decoder *and* `vo=libmpv` — the render API only works through that
+185        // output, and the default would try to open a window of its own.
+186        //
+187        // Set at construction because mpv resolves the video output when it
+188        // initialises; flipping it later does not re-open one.
+189        //
+190        // TRACES: UR-080 | DR-231, DR-235
+191        if super::native_video::enabled() {
+192            mpv.set_property("vo", "libmpv").map_err(|e| PlayerError {
+193                message: format!("Failed to select the libmpv video output: {:?}", e),
+194            })?;
+195            info!("[MpvBackend] native video enabled (vo=libmpv)");
+196        } else {
+197            mpv.set_property("video", "no").map_err(|e| PlayerError {
+198                message: format!("Failed to configure MPV video: {:?}", e),
+199            })?;
+200        }
+201
+202        // Set volume to 100% (we'll control via MPV's volume property)
+203        mpv.set_property("volume", 100i64)
+204            .map_err(|e| PlayerError {
+205                message: format!("Failed to set initial volume: {:?}", e),
+206            })?;
+207
+208        // Survive a flaky connection instead of dying on it. Without these,
+209        // ffmpeg's HTTP demuxer gives up the moment a read fails and MPV raises
+210        // EndFile(ERROR) — a blip on wifi kills the track outright. Reconnecting
+211        // in the demuxer handles the common case entirely below our level, so
+212        // most outages never reach the recovery in `player_recover_stream`.
+213        //
+214        // Non-fatal: these are ffmpeg-side options whose availability varies with
+215        // the libmpv/ffmpeg build, and losing resilience is not a reason to
+216        // refuse to play anything (graceful backend init, CLAUDE.md).
+217        mpv.set_property(
+218            "stream-lavf-o",
+219            "reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5",
+220        )
+221        .unwrap_or_else(|e| {
+222            warn!(
+223                "[MpvBackend] Could not enable stream reconnection: {:?} — \
+224                 playback will not survive network interruptions",
+225                e
+226            );
+227        });
+228        mpv.set_property("network-timeout", 15i64)
+229            .unwrap_or_else(|e| {
+230                warn!("[MpvBackend] Could not set network timeout: {:?}", e);
+231            });
+232
+233        let state = Arc::new(Mutex::new(InternalState {
+234            current_media: None,
+235            volume: 1.0,
+236        }));
+237
+238        let backend = MpvBackend {
+239            mpv: {
+240                let mpv = Arc::new(mpv);
+241                // Publish the handle for the video surface (DR-231). Ignores a
+242                // second call: only one MPV backend is ever constructed, and a
+243                // failed re-init must not replace a live handle.
+244                let _ = MPV_HANDLE.set(mpv.ctx.as_ptr() as usize);
+245                mpv
+246            },
+247            state,
+248            event_emitter,
+249            audio_settings: AudioSettings::default(),
+250            playback_reporter,
+251            position_throttler,
+252            last_seek_time: Arc::new(AtomicU64::new(0)),
+253            pending_seek: Arc::new(Mutex::new(None)),
+254            observed: Arc::new(Mutex::new(ObservedTime::default())),
+255        };
+256
+257        // Start event loop in background thread
+258        backend.start_event_loop();
+259
+260        info!("[MpvBackend] Initialized successfully");
+261        Ok(backend)
+262    }
+263
+264    /// Start the MPV event loop in a background thread
+265    fn start_event_loop(&self) {
+266        let mpv = self.mpv.clone();
+267        let event_emitter = self.event_emitter.clone();
+268        let state = self.state.clone();
+269        let reporter = self.playback_reporter.clone();
+270        let throttler = self.position_throttler.clone();
+271        let pending_seek_for_events = self.pending_seek.clone();
+272
+273        std::thread::spawn(move || {
+274            info!("[MpvBackend] Event loop started");
+275
+276            let mut ev_ctx = mpv.create_event_context();
+277            ev_ctx.disable_deprecated_events().unwrap_or_else(|e| {
+278                error!("[MpvBackend] Failed to disable deprecated events: {:?}", e);
+279            });
+280
+281            // libmpv delivers PropertyChange only for properties registered
+282            // here. Every name matched in the loop below needs a line in this
+283            // block or its handler is unreachable — an omission that reads as
+284            // working code, because the handler is sitting right there.
+285            // UT-218 holds the two lists together.
+286            //
+287            // `pause` drives the play/pause control: the UI consumes
+288            // StateChanged rather than tracking playback itself, per the
+289            // one-directional state rule. Unobserved, the event never came and
+290            // the button never moved. Invisible until native video shipped,
+291            // because the webview <video> element's own DOM events drove that
+292            // control on Linux.
+293            //
+294            // TRACES: UR-005 | DR-239
+295            ev_ctx
+296                .observe_property("pause", libmpv::Format::Flag, 0)
+297                .unwrap_or_else(|e| {
+298                    error!(
+299                        "[MpvBackend] Failed to observe 'pause': {:?} — the play/pause \
+300                         control will not follow the player",
+301                        e
+302                    );
+303                });
+304
+305            loop {
+306                match ev_ctx.wait_event(1.0) {
+307                    Some(Ok(event)) => match event {
+308                        libmpv::events::Event::StartFile => {
+309                            debug!("[MpvBackend] Starting file");
+310                        }
+311                        libmpv::events::Event::FileLoaded => {
+312                            info!("[MpvBackend] File loaded");
+313
+314                            // Apply a seek that arrived while there was nothing
+315                            // to seek in. TRACES: UR-040, UR-005 | DR-241
+316                            {
+317                                let target = pending_seek_for_events.lock_safe().take();
+318                                if let Some(position) = target {
+319                                    match mpv.set_property("time-pos", position) {
+320                                        Ok(()) => info!(
+321                                            "[MpvBackend] applied deferred seek to {position}"
+322                                        ),
+323                                        Err(e) => warn!(
+324                                            "[MpvBackend] deferred seek to {position} failed: {:?}",
+325                                            e
+326                                        ),
+327                                    }
+328                                }
+329                            }
+330
+331                            // Geometry, so "the picture does not fill the screen"
+332                            // can be attributed rather than guessed at. `width`/
+333                            // `height` are the decoded frame; `dwidth`/`dheight`
+334                            // are what mpv will *display* after aspect
+335                            // correction. A file that carries its letterbox
+336                            // baked into the picture reports a 16:9 dwidth and
+337                            // is then pillarboxed on a wider panel — which looks
+338                            // identical to a rendering bug from outside.
+339                            {
+340                                let n = |k: &str| mpv.get_property::<i64>(k).unwrap_or(-1);
+341                                info!(
+342                                    "[MpvBackend] video geometry: {}x{} decoded, {}x{} display, aspect {:?}",
+343                                    n("width"),
+344                                    n("height"),
+345                                    n("dwidth"),
+346                                    n("dheight"),
+347                                    mpv.get_property::<f64>("video-params/aspect").ok(),
+348                                );
+349                            }
+350
+351                            // Get duration
+352                            if let Ok(duration) = mpv.get_property::<f64>("duration") {
+353                                if let Some(emitter) = &event_emitter {
+354                                    emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
+355                                }
+356                            }
+357                        }
+358                        libmpv::events::Event::PlaybackRestart => {
+359                            debug!("[MpvBackend] Playback started/resumed");
+360
+361                            let media_id = state
+362                                .lock_safe()
+363                                .current_media
+364                                .as_ref()
+365                                .map(|m| m.id.clone());
+366
+367                            if let Some(emitter) = &event_emitter {
+368                                emitter.emit(PlayerStatusEvent::StateChanged {
+369                                    state: "playing".to_string(),
+370                                    media_id,
+371                                });
+372                            }
+373                        }
+374                        libmpv::events::Event::PropertyChange { name: "pause", .. } => {
+375                            // Handle pause state changes
+376                            if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
+377                                let media_id = state
+378                                    .lock_safe()
+379                                    .current_media
+380                                    .as_ref()
+381                                    .map(|m| m.id.clone());
+382
+383                                if let Some(emitter) = &event_emitter {
+384                                    emitter.emit(PlayerStatusEvent::StateChanged {
+385                                        state: if is_paused { "paused" } else { "playing" }
+386                                            .to_string(),
+387                                        media_id,
+388                                    });
+389                                }
+390                            }
+391                        }
+392                        libmpv::events::Event::EndFile(reason) => {
+393                            debug!("[MpvBackend] End file with reason: {}", reason);
+394
+395                            // Only emit PlaybackEnded for natural track completion (EOF = 0)
+396                            // Don't emit for Stop (2), Quit (3), Error (4), or other reasons
+397                            // Constants from MPV_END_FILE_REASON enum: EOF=0, STOP=2, QUIT=3, ERROR=4
+398                            const MPV_END_FILE_REASON_EOF: u32 = 0;
+399                            const MPV_END_FILE_REASON_STOP: u32 = 2;
+400                            const MPV_END_FILE_REASON_QUIT: u32 = 3;
+401                            const MPV_END_FILE_REASON_ERROR: u32 = 4;
+402
+403                            if reason == MPV_END_FILE_REASON_EOF {
+404                                debug!("[MpvBackend] Track finished naturally (EOF), emitting PlaybackEnded");
+405                                if let Some(emitter) = &event_emitter {
+406                                    emitter.emit(PlayerStatusEvent::PlaybackEnded);
+407                                }
+408                            } else if reason == MPV_END_FILE_REASON_STOP {
+409                                debug!("[MpvBackend] Track stopped (loading new track), NOT emitting PlaybackEnded");
+410                                // Don't emit - user is loading a new track
+411                            } else if reason == MPV_END_FILE_REASON_QUIT {
+412                                debug!("[MpvBackend] Player quitting, NOT emitting PlaybackEnded");
+413                                // Don't emit - player is shutting down
+414                            } else if reason == MPV_END_FILE_REASON_ERROR {
+415                                // NOT PlaybackEnded — the track did not finish, so
+416                                // autoplay must not advance. It is an error, and it
+417                                // has to be *said*: emitting nothing here left
+418                                // playback halted with the UI still showing
+419                                // "playing" and no way back. Marked recoverable so
+420                                // the frontend echoes it into player_recover_stream,
+421                                // which re-opens the stream where it stopped —
+422                                // MPV's own reconnect handles shorter blips before
+423                                // they ever get this far.
+424                                warn!("[MpvBackend] Track ended with an error — reporting as recoverable");
+425                                if let Some(emitter) = &event_emitter {
+426                                    emitter.emit(PlayerStatusEvent::Error {
+427                                        message: "Playback stream failed".to_string(),
+428                                        recoverable: true,
+429                                    });
+430                                }
+431                            } else {
+432                                debug!("[MpvBackend] Unknown end file reason {}, NOT emitting PlaybackEnded", reason);
+433                            }
+434                        }
+435                        libmpv::events::Event::Shutdown => {
+436                            info!("[MpvBackend] Shutdown event received");
+437                            break;
+438                        }
+439                        _ => {}
+440                    },
+441                    Some(Err(e)) => {
+442                        error!("[MpvBackend] Event error: {:?}", e);
+443                    }
+444                    None => {
+445                        // Timeout, continue
+446                    }
+447                }
+448
+449                std::thread::sleep(Duration::from_millis(10));
+450            }
+451
+452            info!("[MpvBackend] Event loop ended");
+453        });
+454
+455        // Start position update thread
+456        let mpv_for_position = self.mpv.clone();
+457        let emitter_for_position = self.event_emitter.clone();
+458        let state_for_position = self.state.clone();
+459        let reporter_for_position = reporter.clone();
+460        let throttler_for_position = throttler.clone();
+461        let last_seek_time_for_position = self.last_seek_time.clone();
+462        let observed_for_position = self.observed.clone();
+463
+464        std::thread::spawn(move || {
+465            loop {
+466                std::thread::sleep(Duration::from_millis(250));
+467
+468                // Get current position and duration
+469                // Note: We emit position updates even when paused so scrubbing works
+470                if let (Ok(pos), Ok(dur)) = (
+471                    mpv_for_position.get_property::<f64>("time-pos"),
+472                    mpv_for_position.get_property::<f64>("duration"),
+473                ) {
+474                    // Remember it: both properties belong to the *loaded* file and
+475                    // stop resolving the instant MPV unloads it at EOF, which is
+476                    // exactly when end-of-file handling asks where playback got to.
+477                    // Recorded before the post-seek skip below so a track that ends
+478                    // right after a seek still reports the seek target, not zero.
+479                    observed_for_position.lock_safe().record(pos, dur);
+480
+481                    // Check if we recently seeked - skip position updates briefly after seeks
+482                    // to avoid "jumping to zero" visual glitches while MPV is seeking
+483                    let now = SystemTime::now()
+484                        .duration_since(UNIX_EPOCH)
+485                        .unwrap()
+486                        .as_millis() as u64;
+487                    let last_seek = last_seek_time_for_position.load(Ordering::Relaxed);
+488                    let time_since_seek = now.saturating_sub(last_seek);
+489
+490                    // Skip position updates for 150ms after a seek to let MPV stabilize
+491                    if time_since_seek < 150 {
+492                        continue;
+493                    }
+494
+495                    // Emit position update event (even when paused, for scrubbing)
+496                    if let Some(emitter) = &emitter_for_position {
+497                        emitter.emit(PlayerStatusEvent::PositionUpdate {
+498                            position: pos,
+499                            duration: dur,
+500                        });
+501                    }
+502
+503                    // Check if we're playing for progress reporting
+504                    let is_paused = mpv_for_position
+505                        .get_property::<bool>("pause")
+506                        .unwrap_or(true);
+507
+508                    // Only report progress to server when playing (not paused)
+509                    if !is_paused {
+510                        // Throttled progress reporting (every 30s)
+511                        let jellyfin_id = {
+512                            let state = state_for_position.lock_safe();
+513                            state
+514                                .current_media
+515                                .as_ref()
+516                                .and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
+517                        };
+518
+519                        if let Some(item_id) = jellyfin_id {
+520                            if throttler_for_position.should_report(&item_id) {
+521                                let position_ticks = seconds_to_ticks(pos);
+522                                let reporter_clone = reporter_for_position.clone();
+523                                let item_id_clone = item_id.clone();
+524
+525                                // Spawn async task to report progress
+526                                // Check if we're in a Tokio runtime, otherwise spawn a new thread with its own runtime
+527                                if let Ok(handle) = tokio::runtime::Handle::try_current() {
+528                                    handle.spawn(async move {
+529                                        let reporter_guard = reporter_clone.lock().await;
+530                                        if let Some(reporter_instance) = reporter_guard.as_ref() {
+531                                            let operation = PlaybackOperation::Progress {
+532                                                item_id: item_id_clone.clone(),
+533                                                position_ticks,
+534                                                is_paused: false,
+535                                            };
+536
+537                                            match reporter_instance.report(operation, true).await {
+538                                                Ok(_) => debug!(
+539                                                    "[MpvBackend] Reported progress for {}",
+540                                                    item_id_clone
+541                                                ),
+542                                                Err(e) => warn!(
+543                                                    "[MpvBackend] Failed to report progress: {}",
+544                                                    e
+545                                                ),
+546                                            }
+547                                        }
+548                                    });
+549                                } else {
+550                                    // Fallback: spawn in a new thread with its own runtime
+551                                    std::thread::spawn(move || {
+552                                        let rt = tokio::runtime::Runtime::new().unwrap();
+553                                        rt.block_on(async move {
+554                                            let reporter_guard = reporter_clone.lock().await;
+555                                            if let Some(reporter_instance) = reporter_guard.as_ref() {
+556                                                let operation = PlaybackOperation::Progress {
+557                                                    item_id: item_id_clone.clone(),
+558                                                    position_ticks,
+559                                                    is_paused: false,
+560                                                };
+561
+562                                                match reporter_instance.report(operation, true).await {
+563                                                    Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
+564                                                    Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
+565                                                }
+566                                            }
+567                                        });
+568                                    });
+569                                }
+570
+571                                throttler_for_position.mark_reported(&item_id);
+572                            }
+573                        }
+574                    }
+575                }
+576            }
+577        });
+578    }
+579}
+580
+581impl PlayerBackend for MpvBackend {
+582    fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
+583        let stream_url = get_stream_url(media);
+584        info!("[MpvBackend] Loading: {} - {}", media.title, stream_url);
+585
+586        // Update state
+587        {
+588            let mut state = self.state.lock_safe();
+589            state.current_media = Some(media.clone());
+590        }
+591        // A different file: the previous one's timestamp must not survive as this
+592        // one's "last observed" position.
+593        self.observed.lock_safe().reset();
+594
+595        // Load the media file
+596        self.mpv
+597            .command("loadfile", &[&stream_url])
+598            .map_err(|e| PlayerError {
+599                message: format!("Failed to load file: {:?}", e),
+600            })?;
+601
+602        debug!("[MpvBackend] Load command sent successfully");
+603        Ok(())
+604    }
+605
+606    fn play(&mut self) -> Result<(), PlayerError> {
+607        debug!("[MpvBackend] Play command");
+608
+609        self.mpv
+610            .set_property("pause", false)
+611            .map_err(|e| PlayerError {
+612                message: format!("Failed to play: {:?}", e),
+613            })?;
+614
+615        Ok(())
+616    }
+617
+618    fn pause(&mut self) -> Result<(), PlayerError> {
+619        debug!("[MpvBackend] Pause command");
+620
+621        self.mpv
+622            .set_property("pause", true)
+623            .map_err(|e| PlayerError {
+624                message: format!("Failed to pause: {:?}", e),
+625            })?;
+626
+627        Ok(())
+628    }
+629
+630    fn stop(&mut self) -> Result<(), PlayerError> {
+631        debug!("[MpvBackend] Stop command");
+632
+633        self.mpv.command("stop", &[]).map_err(|e| PlayerError {
+634            message: format!("Failed to stop: {:?}", e),
+635        })?;
+636
+637        let mut state = self.state.lock_safe();
+638        state.current_media = None;
+639
+640        Ok(())
+641    }
+642
+643    fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
+644        debug!("[MpvBackend] Seek to {} seconds", position);
+645
+646        // Record the seek time to suppress position updates briefly
+647        let now = SystemTime::now()
+648            .duration_since(UNIX_EPOCH)
+649            .unwrap()
+650            .as_millis() as u64;
+651        self.last_seek_time.store(now, Ordering::Relaxed);
+652
+653        // `time-pos` only resolves while a file is loaded. `loadfile` is
+654        // asynchronous, so a seek issued straight after a reload — resume, or a
+655        // transcoded seek — lands in a window where this fails, and dropping it
+656        // there is what makes the stream play from zero instead of the position
+657        // that was asked for. Hold it and let `FileLoaded` apply it.
+658        // TRACES: UR-040, UR-005 | DR-241
+659        if let Err(e) = self.mpv.set_property("time-pos", position) {
+660            debug!(
+661                "[MpvBackend] seek to {position} deferred until the file loads ({:?})",
+662                e
+663            );
+664            *self.pending_seek.lock_safe() = Some(position);
+665            self.observed.lock_safe().record_position(position);
+666            return Ok(());
+667        }
+668
+669        // A seek that lands clears any earlier deferred one: the newer intent wins.
+670        *self.pending_seek.lock_safe() = None;
+671
+672        // The poll thread suppresses updates for 150ms after a seek, so without
+673        // this a file ending inside that window would report the pre-seek time.
+674        self.observed.lock_safe().record_position(position);
+675
+676        Ok(())
+677    }
+678
+679    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
+680        let clamped = volume.clamp(0.0, 1.0);
+681        debug!("[MpvBackend] Set volume to {}", clamped);
+682
+683        // MPV expects volume as percentage (0-100)
+684        let mpv_volume = volume_to_percent(clamped as f64) as i64;
+685
+686        self.mpv
+687            .set_property("volume", mpv_volume)
+688            .map_err(|e| PlayerError {
+689                message: format!("Failed to set volume: {:?}", e),
+690            })?;
+691
+692        let mut state = self.state.lock_safe();
+693        state.volume = clamped;
+694
+695        Ok(())
+696    }
+697
+698    /// Current position — the live `time-pos`, or the last one observed while a
+699    /// file was loaded.
+700    ///
+701    /// The fallback is the point: `time-pos` is a property of the *loaded* file,
+702    /// so at EOF it stops resolving and a bare `unwrap_or(0.0)` reported 0:00 at
+703    /// exactly the moment end-of-file handling asks where playback reached.
+704    ///
+705    /// TRACES: UR-005 | DR-130 | UT-121
+706    fn position(&self) -> f64 {
+707        let live = self.mpv.get_property::<f64>("time-pos").ok();
+708        self.observed.lock_safe().position_or_last(live)
+709    }
+710
+711    /// Total duration — live, or the last one observed. Unloaded at EOF for the
+712    /// same reason as `position`.
+713    ///
+714    /// TRACES: UR-005 | DR-130 | UT-121
+715    fn duration(&self) -> Option<f64> {
+716        let live = self.mpv.get_property::<f64>("duration").ok();
+717        self.observed.lock_safe().duration_or_last(live)
+718    }
+719
+720    fn state(&self) -> PlayerState {
+721        let state = self.state.lock_safe();
+722
+723        if let Some(ref media) = state.current_media {
+724            let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
+725            let position = self.position();
+726            let duration = self.duration().unwrap_or(0.0);
+727
+728            if is_paused {
+729                PlayerState::Paused {
+730                    media: media.clone(),
+731                    position,
+732                    duration,
+733                }
+734            } else {
+735                PlayerState::Playing {
+736                    media: media.clone(),
+737                    position,
+738                    duration,
+739                }
+740            }
+741        } else {
+742            PlayerState::Idle
+743        }
+744    }
+745
+746    fn volume(&self) -> f32 {
+747        let state = self.state.lock_safe();
+748        state.volume
+749    }
+750
+751    fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
+752        info!("[MpvBackend] Applying audio settings");
+753        self.audio_settings = settings.clone();
+754
+755        // Apply gapless playback
+756        if settings.gapless_playback {
+757            self.mpv
+758                .set_property("gapless-audio", "yes")
+759                .map_err(|e| PlayerError {
+760                    message: format!("Failed to enable gapless: {:?}", e),
+761                })?;
+762        } else {
+763            self.mpv
+764                .set_property("gapless-audio", "no")
+765                .map_err(|e| PlayerError {
+766                    message: format!("Failed to disable gapless: {:?}", e),
+767                })?;
+768        }
+769
+770        // Audio filter chain: build a single lavfi graph combining the EQ
+771        // peaking bands and (optionally) a dynamic loudness normalizer, and
+772        // set the `af` property. An empty string clears all filters. Both
+773        // features share one `af` graph because MPV exposes a single filter
+774        // property. See docs/architecture/05-platform-backends.md and IR-020.
+775        let af = build_af_filter(settings);
+776        self.mpv
+777            .set_property("af", af.as_str())
+778            .map_err(|e| PlayerError {
+779                message: format!("Failed to set audio filters: {:?}", e),
+780            })?;
+781
+782        // TODO: Implement crossfade via MPV audio filters if needed
+783
+784        Ok(())
+785    }
+786
+787    fn audio_settings(&self) -> AudioSettings {
+788        self.audio_settings.clone()
+789    }
+790}
+791
+792/// Build the full MPV `af` (audio filter) value from the audio settings.
+793///
+794/// Combines the equalizer peaking bands and the loudness-normalization filter
+795/// into a single `lavfi` graph, because MPV exposes one `af` property. The
+796/// normalizer runs *after* the EQ so it levels the post-EQ signal. Returns an
+797/// empty string when neither feature contributes a filter, which clears `af`.
+798///
+799/// TRACES: UR-027, UR-033 | IR-020, DR-036
+800fn build_af_filter(settings: &AudioSettings) -> String {
+801    let mut entries = eq_filter_entries(settings.equalizer_enabled, &settings.equalizer_bands);
+802    if let Some(norm) = normalize_filter_entry(settings.normalize_volume, settings.volume_level) {
+803        entries.push(norm);
+804    }
+805
+806    if entries.is_empty() {
+807        return String::new();
+808    }
+809    format!("lavfi=[{}]", entries.join(","))
+810}
+811
+812/// Peaking-EQ filter entries (unwrapped), one ffmpeg `equalizer` (two-pole
+813/// peaking) per band with a non-zero gain, e.g.
+814/// `equalizer=f=31:width_type=o:width=1:g=5`. Returns an empty vec when the EQ
+815/// is disabled or every gain is ~0. Gains are assumed already normalised by
+816/// [`AudioSettings::with_equalizer_normalised`]; bands beyond [`EQ_BANDS`] are
+817/// ignored.
+818///
+819/// TRACES: UR-027 | IR-020
+820fn eq_filter_entries(enabled: bool, bands: &[f32]) -> Vec<String> {
+821    if !enabled {
+822        return Vec::new();
+823    }
+824    bands
+825        .iter()
+826        .zip(EQ_BANDS.iter())
+827        .filter(|(gain, _)| gain.abs() >= 0.05) // skip ~0 dB bands
+828        .map(|(gain, freq)| {
+829            // width_type=o → octave bandwidth; width=1 → one octave per band.
+830            format!("equalizer=f={}:width_type=o:width=1:g={}", freq, gain)
+831        })
+832        .collect()
+833}
+834
+835/// Reference peak (`dynaudnorm` `p`, linear amplitude) for the default
+836/// [`VolumeLevel::Normal`] (−14 LUFS) target, leaving −1.2 dB of headroom.
+837const NORMALIZE_REF_PEAK: f32 = 0.87;
+838/// Reference loudness the peak table is anchored at (Normal preset, −14 LUFS).
+839const NORMALIZE_REF_LUFS: f32 = -14.0;
+840
+841/// The loudness-normalization filter entry (unwrapped), or `None` when
+842/// normalization is disabled. Uses ffmpeg's `dynaudnorm`, a gentle real-time
+843/// dynamic normalizer that avoids the gain "pumping" `loudnorm`'s single-pass
+844/// mode can produce on very dynamic material.
+845///
+846/// `dynaudnorm` targets a peak amplitude (`p`, linear 0–1), not a LUFS value,
+847/// so the Loud/Normal/Quiet presets become *approximate*: each preset's LUFS
+848/// offset from the Normal reference is applied as a dB offset to the reference
+849/// peak, preserving the Loud > Normal > Quiet ordering. `g=15` (gaussian window
+850/// size) further smooths gain changes; the peak is clamped to a safe (0, 0.99]
+851/// so loud presets never request full-scale.
+852///
+853/// TRACES: UR-033 | DR-036
+854fn normalize_filter_entry(enabled: bool, level: VolumeLevel) -> Option<String> {
+855    if !enabled {
+856        return None;
+857    }
+858    // LUFS above the reference → louder → higher peak; each +1 LUFS ≈ +1 dB.
+859    let db_offset = level.target_lufs() - NORMALIZE_REF_LUFS;
+860    let peak = (NORMALIZE_REF_PEAK * 10f32.powf(db_offset / 20.0)).clamp(0.10, 0.99);
+861    // 3 decimals is plenty for a peak target and keeps the filter string stable.
+862    Some(format!("dynaudnorm=p={:.3}:g=15", peak))
+863}
+864
+865impl Drop for MpvBackend {
+866    fn drop(&mut self) {
+867        info!("[MpvBackend] Shutting down");
+868        // MPV will be automatically cleaned up
+869    }
+870}
+871
+872#[cfg(test)]
+873mod af_filter_tests {
+874    use super::{build_af_filter, eq_filter_entries, normalize_filter_entry};
+875    use crate::settings::{AudioSettings, VolumeLevel};
+876
+877    fn settings() -> AudioSettings {
+878        AudioSettings {
+879            equalizer_enabled: false,
+880            equalizer_bands: vec![0.0; 10],
+881            normalize_volume: false,
+882            ..AudioSettings::default()
+883        }
+884    }
+885
+886    /// Disabled EQ, or an all-zero curve, produces no EQ entries.
+887    ///
+888    /// TRACES: UR-027 | IR-020 | UT-083
+889    #[test]
+890    fn test_eq_entries_empty_when_disabled_or_flat() {
+891        assert!(eq_filter_entries(false, &[5.0, -3.0, 2.0]).is_empty());
+892        assert!(eq_filter_entries(true, &[0.0; 10]).is_empty());
+893        // Sub-threshold gains count as flat.
+894        assert!(eq_filter_entries(true, &[0.01, -0.02]).is_empty());
+895    }
+896
+897    /// Enabled EQ builds one peaking `equalizer` per non-zero band at the right
+898    /// centre frequency and gain, chained inside a single `lavfi` filter.
+899    ///
+900    /// TRACES: UR-027 | IR-020 | UT-084
+901    #[test]
+902    fn test_eq_filter_builds_lavfi_chain() {
+903        // First band (31 Hz) +5 dB, third band (125 Hz) -2 dB, rest flat.
+904        let mut s = settings();
+905        s.equalizer_enabled = true;
+906        s.equalizer_bands = vec![5.0, 0.0, -2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
+907        let af = build_af_filter(&s);
+908        assert!(af.starts_with("lavfi=["), "wrapped in lavfi: {af}");
+909        assert!(af.ends_with("]"));
+910        assert!(af.contains("equalizer=f=31:width_type=o:width=1:g=5"));
+911        assert!(af.contains("equalizer=f=125:width_type=o:width=1:g=-2"));
+912        // Only two bands are non-zero → exactly two peaking filters.
+913        assert_eq!(af.matches("equalizer=").count(), 2);
+914    }
+915
+916    /// Disabled normalization yields no filter entry; the combined `af` for a
+917    /// fully default (all-off) settings is empty, which clears `af`.
+918    ///
+919    /// TRACES: UR-033 | DR-036 | UT-085
+920    #[test]
+921    fn test_normalize_disabled_produces_no_filter() {
+922        assert!(normalize_filter_entry(false, VolumeLevel::Normal).is_none());
+923        assert_eq!(build_af_filter(&settings()), "");
+924    }
+925
+926    /// Enabled normalization emits a `dynaudnorm` filter with a peak target, and
+927    /// the peak preserves the Loud > Normal > Quiet ordering.
+928    ///
+929    /// TRACES: UR-033 | DR-036 | UT-086
+930    #[test]
+931    fn test_normalize_peak_preserves_preset_ordering() {
+932        fn peak_of(entry: &str) -> f32 {
+933            // "dynaudnorm=p=0.870:g=15" → 0.870
+934            entry
+935                .split("p=")
+936                .nth(1)
+937                .and_then(|s| s.split(':').next())
+938                .and_then(|s| s.parse().ok())
+939                .expect("parseable peak")
+940        }
+941
+942        let loud = normalize_filter_entry(true, VolumeLevel::Loud).unwrap();
+943        let normal = normalize_filter_entry(true, VolumeLevel::Normal).unwrap();
+944        let quiet = normalize_filter_entry(true, VolumeLevel::Quiet).unwrap();
+945        for entry in [&loud, &normal, &quiet] {
+946            assert!(
+947                entry.starts_with("dynaudnorm="),
+948                "dynaudnorm filter: {entry}"
+949            );
+950        }
+951        assert!(
+952            peak_of(&loud) > peak_of(&normal) && peak_of(&normal) > peak_of(&quiet),
+953            "Loud {} > Normal {} > Quiet {}",
+954            peak_of(&loud),
+955            peak_of(&normal),
+956            peak_of(&quiet),
+957        );
+958        // Every preset stays within the safe (0, 0.99] clamp.
+959        for p in [peak_of(&loud), peak_of(&normal), peak_of(&quiet)] {
+960            assert!(p > 0.0 && p <= 0.99, "peak in range: {p}");
+961        }
+962
+963        let mut s = settings();
+964        s.normalize_volume = true;
+965        s.volume_level = VolumeLevel::Quiet;
+966        let af = build_af_filter(&s);
+967        assert!(af.starts_with("lavfi=["));
+968        assert!(af.contains("dynaudnorm=p="));
+969    }
+970
+971    /// EQ and normalization coexist in one `lavfi` graph, with the normalizer
+972    /// placed after the EQ bands so it levels the post-EQ signal.
+973    ///
+974    /// TRACES: UR-027, UR-033 | IR-020, DR-036 | UT-087
+975    #[test]
+976    fn test_eq_and_normalize_combine_in_order() {
+977        let mut s = settings();
+978        s.equalizer_enabled = true;
+979        s.equalizer_bands = vec![5.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
+980        s.normalize_volume = true;
+981        s.volume_level = VolumeLevel::Normal;
+982        let af = build_af_filter(&s);
+983
+984        let eq_pos = af.find("equalizer=").expect("has EQ");
+985        let norm_pos = af.find("dynaudnorm=").expect("has normalizer");
+986        assert!(eq_pos < norm_pos, "normalizer runs after EQ: {af}");
+987    }
+988}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/mpv_player.rs.html b/api/src/jellytau_lib/player/mpv_player.rs.html new file mode 100644 index 00000000..52106da2 --- /dev/null +++ b/api/src/jellytau_lib/player/mpv_player.rs.html @@ -0,0 +1,384 @@ +mpv_player.rs - sourceSkip to main content

jellytau_lib/player/
mpv_player.rs

1//! [`MediaPlayer`] over libmpv.
+2//!
+3//! The point of difference from `MpvBackend` is [`MpvPlayer::open`]: the start
+4//! position is applied **at load time**, via mpv's own `start` option, instead
+5//! of being seeked to afterwards. `loadfile` is asynchronous, so a seek issued
+6//! after it targets a player that has nothing loaded, fails, and — under the old
+7//! contract — was discarded. That is DR-241, and it is why resume and transcoded
+8//! skip both played from zero.
+9//!
+10//! A seek arriving during [`Phase::Opening`] is held and applied when the file
+11//! loads, so no caller has to know where that window begins or ends.
+12//!
+13//! TRACES: UR-081, UR-040, UR-005 | DR-244
+14
+15#![allow(dead_code)] // Wired to PlayerController in DR-245.
+16
+17use std::sync::{Arc, Mutex};
+18use std::time::Duration;
+19
+20use libmpv::Mpv;
+21use log::{debug, info, warn};
+22
+23use super::backend::PlayerError;
+24use super::media_player::{
+25    duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot,
+26};
+27use crate::utils::lock::MutexSafe;
+28
+29/// State the event thread writes and the caller reads.
+30#[derive(Debug)]
+31struct Shared {
+32    phase: Phase,
+33    position: Duration,
+34    duration: Option<Duration>,
+35    seekable: bool,
+36    /// A seek that arrived while opening. Applied on `FileLoaded`.
+37    deferred_seek: Option<Duration>,
+38    /// Cleared by `close()`, so an open still in flight cannot come back to life
+39    /// and start playing after the caller has stopped it.
+40    open_generation: u64,
+41}
+42
+43impl Default for Shared {
+44    fn default() -> Self {
+45        Self {
+46            phase: Phase::Idle,
+47            position: Duration::ZERO,
+48            duration: None,
+49            seekable: false,
+50            deferred_seek: None,
+51            open_generation: 0,
+52        }
+53    }
+54}
+55
+56pub struct MpvPlayer {
+57    mpv: Arc<Mpv>,
+58    shared: Arc<Mutex<Shared>>,
+59    volume: f32,
+60    muted: bool,
+61    rate: f64,
+62    audio_track: Option<i32>,
+63    subtitle_track: Option<i32>,
+64}
+65
+66/// How the engine should talk to the machine.
+67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+68pub enum Output {
+69    /// Real audio and video. What the app uses.
+70    Real,
+71    /// No audio device, no window. What conformance uses, so the suite can run
+72    /// on a headless runner without claiming the user's speakers.
+73    Null,
+74}
+75
+76impl MpvPlayer {
+77    pub fn new(output: Output) -> Result<Self, PlayerError> {
+78        // mpv refuses to start under a non-C LC_NUMERIC, and anything that has
+79        // initialised GTK before us will have set one.
+80        unsafe {
+81            let c = std::ffi::CString::new("C").unwrap();
+82            libc::setlocale(libc::LC_NUMERIC, c.as_ptr());
+83        }
+84
+85        let mpv = Mpv::new().map_err(|e| PlayerError {
+86            message: format!("mpv_create failed: {e:?}"),
+87        })?;
+88
+89        let set = |k: &str, v: &str| {
+90            if let Err(e) = mpv.set_property(k, v) {
+91                warn!("[MpvPlayer] could not set {k}={v}: {e:?}");
+92            }
+93        };
+94        match output {
+95            Output::Real => {
+96                set("vo", "libmpv");
+97            }
+98            Output::Null => {
+99                set("ao", "null");
+100                set("vo", "null");
+101            }
+102        }
+103        set("msg-level", "all=warn");
+104        // Survive a blip rather than ending the item on it.
+105        set(
+106            "stream-lavf-o",
+107            "reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5",
+108        );
+109
+110        let player = Self {
+111            mpv: Arc::new(mpv),
+112            shared: Arc::new(Mutex::new(Shared::default())),
+113            volume: 1.0,
+114            muted: false,
+115            rate: 1.0,
+116            audio_track: None,
+117            subtitle_track: None,
+118        };
+119        player.spawn_events();
+120        Ok(player)
+121    }
+122
+123    fn spawn_events(&self) {
+124        let mpv = self.mpv.clone();
+125        let shared = self.shared.clone();
+126
+127        std::thread::spawn(move || {
+128            let mut ev = mpv.create_event_context();
+129            let _ = ev.disable_deprecated_events();
+130            // Every property matched below must be observed, or libmpv never
+131            // delivers it and the handler is unreachable (DR-239).
+132            for prop in ["pause", "eof-reached"] {
+133                if let Err(e) = ev.observe_property(prop, libmpv::Format::Flag, 0) {
+134                    warn!("[MpvPlayer] could not observe {prop}: {e:?}");
+135                }
+136            }
+137
+138            loop {
+139                match ev.wait_event(0.25) {
+140                    Some(Ok(libmpv::events::Event::FileLoaded)) => {
+141                        let deferred = {
+142                            let mut s = shared.lock_safe();
+143                            // Closed while opening: do not start.
+144                            if s.phase == Phase::Idle {
+145                                continue;
+146                            }
+147                            s.duration = mpv
+148                                .get_property::<f64>("duration")
+149                                .ok()
+150                                .and_then(duration_from_secs);
+151                            s.seekable = mpv.get_property::<bool>("seekable").unwrap_or(true);
+152                            s.phase = Phase::Playing;
+153                            s.deferred_seek.take()
+154                        };
+155                        if let Some(to) = deferred {
+156                            debug!("[MpvPlayer] applying deferred seek to {to:?}");
+157                            if let Err(e) = mpv.set_property("time-pos", to.as_secs_f64()) {
+158                                warn!("[MpvPlayer] deferred seek failed: {e:?}");
+159                            }
+160                        }
+161                    }
+162                    Some(Ok(libmpv::events::Event::PropertyChange { name: "pause", .. })) => {
+163                        if let Ok(paused) = mpv.get_property::<bool>("pause") {
+164                            let mut s = shared.lock_safe();
+165                            if s.phase.has_media() {
+166                                s.phase = if paused {
+167                                    Phase::Paused
+168                                } else {
+169                                    Phase::Playing
+170                                };
+171                            }
+172                        }
+173                    }
+174                    Some(Ok(libmpv::events::Event::EndFile(reason))) => {
+175                        let mut s = shared.lock_safe();
+176                        // 0 = EOF. Anything else is a stop, a quit or an error,
+177                        // and must not read as "the item finished".
+178                        s.phase = if reason == 0 {
+179                            Phase::Ended
+180                        } else {
+181                            Phase::Idle
+182                        };
+183                    }
+184                    Some(Ok(libmpv::events::Event::Shutdown)) => break,
+185                    _ => {}
+186                }
+187
+188                if let Ok(pos) = mpv.get_property::<f64>("time-pos") {
+189                    let mut s = shared.lock_safe();
+190                    if s.phase.has_media() && s.deferred_seek.is_none() {
+191                        s.position = Duration::from_secs_f64(pos.max(0.0));
+192                    }
+193                }
+194            }
+195        });
+196    }
+197}
+198
+199impl MediaPlayer for MpvPlayer {
+200    fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
+201        {
+202            let mut s = self.shared.lock_safe();
+203            *s = Shared {
+204                phase: Phase::Opening,
+205                open_generation: s.open_generation + 1,
+206                ..Shared::default()
+207            };
+208            // Report the requested position immediately, so a caller reading
+209            // back during the open sees where it asked to be rather than zero.
+210            s.position = req.start;
+211        }
+212
+213        // The whole point. `start` is applied by mpv as it opens the file, so
+214        // there is no window in which the position can be asked for and lost.
+215        let start = if req.start.is_zero() {
+216            "none".to_string()
+217        } else {
+218            format!("{:.3}", req.start.as_secs_f64())
+219        };
+220        self.mpv
+221            .set_property("start", start.as_str())
+222            .map_err(|e| PlayerError {
+223                message: format!("could not set start position: {e:?}"),
+224            })?;
+225        self.mpv
+226            .set_property("pause", !req.autoplay)
+227            .map_err(|e| PlayerError {
+228                message: format!("could not set pause: {e:?}"),
+229            })?;
+230
+231        info!("[MpvPlayer] open {} at {:?}", req.selection.url, req.start);
+232        self.mpv
+233            .command("loadfile", &[&req.selection.url, "replace"])
+234            .map_err(|e| PlayerError {
+235                message: format!("loadfile failed: {e:?}"),
+236            })?;
+237        Ok(())
+238    }
+239
+240    fn play(&mut self) -> Result<(), PlayerError> {
+241        self.mpv
+242            .set_property("pause", false)
+243            .map_err(|e| PlayerError {
+244                message: format!("play failed: {e:?}"),
+245            })?;
+246        let mut s = self.shared.lock_safe();
+247        if s.phase.has_media() && s.phase != Phase::Opening {
+248            s.phase = Phase::Playing;
+249        }
+250        Ok(())
+251    }
+252
+253    fn pause(&mut self) -> Result<(), PlayerError> {
+254        self.mpv
+255            .set_property("pause", true)
+256            .map_err(|e| PlayerError {
+257                message: format!("pause failed: {e:?}"),
+258            })?;
+259        let mut s = self.shared.lock_safe();
+260        if s.phase.has_media() && s.phase != Phase::Opening {
+261            s.phase = Phase::Paused;
+262        }
+263        Ok(())
+264    }
+265
+266    fn close(&mut self) -> Result<(), PlayerError> {
+267        // State first: an open still in flight checks this on FileLoaded and
+268        // must not proceed to play after the caller has stopped it.
+269        {
+270            let mut s = self.shared.lock_safe();
+271            *s = Shared {
+272                open_generation: s.open_generation,
+273                ..Shared::default()
+274            };
+275        }
+276        // Idempotent: stopping an already-stopped mpv is not an error worth
+277        // propagating, and callers legitimately close twice on teardown.
+278        if let Err(e) = self.mpv.command("stop", &[]) {
+279            debug!("[MpvPlayer] stop on an idle player: {e:?}");
+280        }
+281        Ok(())
+282    }
+283
+284    fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
+285        {
+286            let mut s = self.shared.lock_safe();
+287            match s.phase {
+288                // Held, not dropped. The caller cannot see this window.
+289                Phase::Opening => {
+290                    s.deferred_seek = Some(to);
+291                    s.position = to;
+292                    return Ok(());
+293                }
+294                Phase::Idle | Phase::Failed(_) => {
+295                    return Err(PlayerError {
+296                        message: "seek with nothing open".to_string(),
+297                    })
+298                }
+299                _ => s.position = to,
+300            }
+301        }
+302        self.mpv
+303            .set_property("time-pos", to.as_secs_f64())
+304            .map_err(|e| PlayerError {
+305                message: format!("seek failed: {e:?}"),
+306            })
+307    }
+308
+309    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
+310        let clamped = volume.clamp(0.0, 1.0);
+311        self.volume = clamped;
+312        self.mpv
+313            .set_property("volume", (clamped as f64) * 100.0)
+314            .map_err(|e| PlayerError {
+315                message: format!("set_volume failed: {e:?}"),
+316            })
+317    }
+318
+319    fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError> {
+320        self.muted = muted;
+321        self.mpv
+322            .set_property("mute", muted)
+323            .map_err(|e| PlayerError {
+324                message: format!("set_muted failed: {e:?}"),
+325            })
+326    }
+327
+328    fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError> {
+329        self.rate = rate;
+330        self.mpv
+331            .set_property("speed", rate)
+332            .map_err(|e| PlayerError {
+333                message: format!("set_rate failed: {e:?}"),
+334            })
+335    }
+336
+337    fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
+338        self.audio_track = index;
+339        let value = index.map(|i| i.to_string()).unwrap_or_else(|| "no".into());
+340        self.mpv
+341            .set_property("aid", value.as_str())
+342            .map_err(|e| PlayerError {
+343                message: format!("select_audio_track failed: {e:?}"),
+344            })
+345    }
+346
+347    fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
+348        self.subtitle_track = index;
+349        let value = index.map(|i| i.to_string()).unwrap_or_else(|| "no".into());
+350        self.mpv
+351            .set_property("sid", value.as_str())
+352            .map_err(|e| PlayerError {
+353                message: format!("select_subtitle_track failed: {e:?}"),
+354            })
+355    }
+356
+357    fn snapshot(&self) -> PlaybackSnapshot {
+358        let s = self.shared.lock_safe();
+359        PlaybackSnapshot {
+360            phase: s.phase.clone(),
+361            position: s.position,
+362            duration: s.duration,
+363            seekable: s.seekable,
+364            volume: self.volume,
+365            muted: self.muted,
+366            rate: self.rate,
+367            audio_track: self.audio_track,
+368            subtitle_track: self.subtitle_track,
+369        }
+370    }
+371
+372    fn capabilities(&self) -> Capabilities {
+373        Capabilities {
+374            video: true,
+375            audio_settings: true,
+376            subtitle_switching: true,
+377            audio_track_switching: true,
+378            // mpv's HLS demuxer cannot make the server transcode from a new
+379            // offset, so a transcoded seek must re-open the stream.
+380            seeks_transcoded_in_place: false,
+381        }
+382    }
+383}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/mpv_render.rs.html b/api/src/jellytau_lib/player/mpv_render.rs.html new file mode 100644 index 00000000..1217d4f3 --- /dev/null +++ b/api/src/jellytau_lib/player/mpv_render.rs.html @@ -0,0 +1,406 @@ +mpv_render.rs - sourceSkip to main content

jellytau_lib/player/
mpv_render.rs

1//! mpv's render API, driven into an OpenGL framebuffer we own.
+2//!
+3//! This is the half of native video that is not GTK: create a render context
+4//! over the mpv handle the audio backend already drives, render a frame into a
+5//! texture, and hand that texture id back for the toolkit to composite.
+6//!
+7//! Kept apart from `video_surface` deliberately — everything here is portable
+8//! across the platforms this app targets, while the surface that consumes it is
+9//! not. Windows reuses this file unchanged (DR-237).
+10//!
+11//! TRACES: UR-080 | DR-231, DR-232, IR-033
+12
+13use std::ffi::{c_void, CStr, CString};
+14use std::os::raw::{c_char, c_int};
+15use std::ptr;
+16
+17use log::{error, info, warn};
+18
+19/// GL entry points, resolved once.
+20///
+21/// Only the handful needed to own a framebuffer; mpv resolves everything else
+22/// it needs through [`get_proc_address`].
+23struct Gl {
+24    gen_framebuffers: unsafe extern "C" fn(c_int, *mut u32),
+25    delete_framebuffers: unsafe extern "C" fn(c_int, *const u32),
+26    bind_framebuffer: unsafe extern "C" fn(u32, u32),
+27    framebuffer_texture_2d: unsafe extern "C" fn(u32, u32, u32, u32, c_int),
+28    gen_textures: unsafe extern "C" fn(c_int, *mut u32),
+29    delete_textures: unsafe extern "C" fn(c_int, *const u32),
+30    bind_texture: unsafe extern "C" fn(u32, u32),
+31    tex_image_2d:
+32        unsafe extern "C" fn(u32, c_int, c_int, c_int, c_int, c_int, u32, u32, *const c_void),
+33    tex_parameteri: unsafe extern "C" fn(u32, u32, c_int),
+34    check_framebuffer_status: unsafe extern "C" fn(u32) -> u32,
+35}
+36
+37const GL_TEXTURE_2D: u32 = 0x0DE1;
+38const GL_FRAMEBUFFER: u32 = 0x8D40;
+39const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
+40const GL_RGBA: u32 = 0x1908;
+41const GL_RGBA8: c_int = 0x8058;
+42const GL_UNSIGNED_BYTE: u32 = 0x1401;
+43const GL_LINEAR: c_int = 0x2601;
+44const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
+45const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
+46const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
+47
+48/// Resolve a GL symbol the way libepoxy actually exports it.
+49///
+50/// **This is the trap that cost the spike a debugging cycle.** libepoxy does not
+51/// export `glFoo` as a function. It exports `epoxy_glFoo` as a *data* symbol
+52/// holding a lazily-resolving function pointer. So the address `dlsym` returns
+53/// is the address *of the pointer*, not of any code: returning it makes mpv jump
+54/// into non-executable data and take SIGSEGV/SEGV_ACCERR on the very first GL
+55/// call. The value must be read *out of* that location.
+56///
+57/// The `epoxy` crate does this correctly and is unusable here — its
+58/// `gl_generator` dependency pulls a yanked `xml-rs`.
+59///
+60/// TRACES: UR-080 | IR-033
+61unsafe fn resolve(name: &str) -> *mut c_void {
+62    let epoxy_name = match CString::new(format!("epoxy_{name}")) {
+63        Ok(n) => n,
+64        Err(_) => return ptr::null_mut(),
+65    };
+66    let slot = libc::dlsym(libc::RTLD_DEFAULT, epoxy_name.as_ptr());
+67    if !slot.is_null() {
+68        // The symbol holds the function pointer; return what is stored there.
+69        return *(slot as *mut *mut c_void);
+70    }
+71
+72    // Fall back to a plain symbol, for a GL stack that is not behind epoxy.
+73    match CString::new(name) {
+74        Ok(n) => libc::dlsym(libc::RTLD_DEFAULT, n.as_ptr()),
+75        Err(_) => ptr::null_mut(),
+76    }
+77}
+78
+79/// What mpv calls to find GL entry points. Same rule as [`resolve`].
+80unsafe extern "C" fn get_proc_address(_ctx: *mut c_void, name: *const c_char) -> *mut c_void {
+81    if name.is_null() {
+82        return ptr::null_mut();
+83    }
+84    match CStr::from_ptr(name).to_str() {
+85        Ok(n) => resolve(n),
+86        Err(_) => ptr::null_mut(),
+87    }
+88}
+89
+90macro_rules! load {
+91    ($name:literal) => {{
+92        let p = resolve($name);
+93        if p.is_null() {
+94            error!("[MpvRender] GL symbol not found: {}", $name);
+95            return None;
+96        }
+97        std::mem::transmute(p)
+98    }};
+99}
+100
+101impl Gl {
+102    /// Resolve every entry point, or none — a partially-loaded table would fail
+103    /// later at a call site with no context.
+104    ///
+105    /// The transmutes are unannotated on purpose: each target type is declared
+106    /// once on the struct field above, and repeating it at the call site would
+107    /// be two places to get the same signature wrong.
+108    #[allow(clippy::missing_transmute_annotations)]
+109    unsafe fn load() -> Option<Self> {
+110        Some(Gl {
+111            gen_framebuffers: load!("glGenFramebuffers"),
+112            delete_framebuffers: load!("glDeleteFramebuffers"),
+113            bind_framebuffer: load!("glBindFramebuffer"),
+114            framebuffer_texture_2d: load!("glFramebufferTexture2D"),
+115            gen_textures: load!("glGenTextures"),
+116            delete_textures: load!("glDeleteTextures"),
+117            bind_texture: load!("glBindTexture"),
+118            tex_image_2d: load!("glTexImage2D"),
+119            tex_parameteri: load!("glTexParameteri"),
+120            check_framebuffer_status: load!("glCheckFramebufferStatus"),
+121        })
+122    }
+123}
+124
+125/// A colour-renderable framebuffer mpv draws into, sized to the widget.
+126struct Target {
+127    fbo: u32,
+128    texture: u32,
+129    width: i32,
+130    height: i32,
+131}
+132
+133/// mpv's render context plus the framebuffer it draws into.
+134///
+135/// # Lifetime (DR-232)
+136///
+137/// The render context must not outlive the GL context it was created against.
+138/// `Drop` unregisters mpv's update callback *before* freeing the context, so a
+139/// callback cannot land on a freed pointer, and frees the GL objects while the
+140/// caller still has the context current. The caller is responsible for making
+141/// the GL context current around both creation and drop — see `video_surface`.
+142///
+143/// This is DR-184 on Android restated: a surface outliving its player. The spike
+144/// had no defence at all and saw one unexplained SIGSEGV in a decoder thread.
+145pub struct MpvRenderContext {
+146    ctx: *mut libmpv_sys::mpv_render_context,
+147    gl: Gl,
+148    target: Option<Target>,
+149}
+150
+151// The render context is driven only from the GTK main thread; the update
+152// callback merely schedules a redraw and touches nothing here.
+153unsafe impl Send for MpvRenderContext {}
+154
+155impl MpvRenderContext {
+156    /// Create a render context over an existing mpv handle.
+157    ///
+158    /// The GL context must already be current on this thread.
+159    ///
+160    /// TRACES: UR-080 | DR-231, IR-033
+161    pub unsafe fn new(mpv: *mut libmpv_sys::mpv_handle) -> Option<Self> {
+162        let gl = Gl::load()?;
+163
+164        let mut init = libmpv_sys::mpv_opengl_init_params {
+165            get_proc_address: Some(get_proc_address),
+166            get_proc_address_ctx: ptr::null_mut(),
+167        };
+168        let mut api_type = CString::new("opengl").ok()?;
+169        // Advanced control is deliberately OFF.
+170        //
+171        // With it on, mpv expects the client to drive rendering to a stricter
+172        // contract than a GTK draw handler can promise — it will wait on us, and
+173        // if we in turn wait on its update callback, neither side proceeds. That
+174        // deadlock presents as a file that loads, renders one frame, and then
+175        // sits there with no audio and a spinner.
+176        //
+177        // Off, mpv is tolerant of being rendered on the toolkit's schedule,
+178        // which is what the frame clock gives us.
+179        let mut advanced: c_int = 0;
+180
+181        let mut params = [
+182            libmpv_sys::mpv_render_param {
+183                type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_API_TYPE,
+184                data: api_type.as_ptr() as *mut c_void,
+185            },
+186            libmpv_sys::mpv_render_param {
+187                type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_INIT_PARAMS,
+188                data: &mut init as *mut _ as *mut c_void,
+189            },
+190            libmpv_sys::mpv_render_param {
+191                type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_ADVANCED_CONTROL,
+192                data: &mut advanced as *mut _ as *mut c_void,
+193            },
+194            libmpv_sys::mpv_render_param {
+195                type_: 0,
+196                data: ptr::null_mut(),
+197            },
+198        ];
+199
+200        let mut ctx: *mut libmpv_sys::mpv_render_context = ptr::null_mut();
+201        let rc = libmpv_sys::mpv_render_context_create(&mut ctx, mpv, params.as_mut_ptr());
+202        // Keep the CString alive until after the call.
+203        let _ = &mut api_type;
+204
+205        if rc < 0 || ctx.is_null() {
+206            error!("[MpvRender] mpv_render_context_create failed: {rc}");
+207            return None;
+208        }
+209
+210        info!("[MpvRender] render context created");
+211        Some(MpvRenderContext {
+212            ctx,
+213            gl,
+214            target: None,
+215        })
+216    }
+217
+218    /// Ask to be told when a new frame is ready.
+219    ///
+220    /// Paired with [`report_swap`](Self::report_swap): without both, mpv has
+221    /// nothing to time against. The symptom is misleading — playback looks fine
+222    /// in a window and judders at fullscreen, which reads as a compositing or
+223    /// GPU limit and is neither (DR-233).
+224    ///
+225    /// TRACES: UR-080 | DR-233
+226    pub unsafe fn set_update_callback(
+227        &mut self,
+228        callback: libmpv_sys::mpv_render_update_fn,
+229        ctx: *mut c_void,
+230    ) {
+231        libmpv_sys::mpv_render_context_set_update_callback(self.ctx, callback, ctx);
+232    }
+233
+234    /// Whether mpv has a new frame waiting.
+235    ///
+236    /// Asked of mpv directly rather than inferred from its update callback, and
+237    /// that distinction is the whole of frame pacing here:
+238    ///
+239    ///   - Waiting only on the callback deadlocks — mpv will not progress until
+240    ///     the client renders, so if the client will not render until mpv says
+241    ///     so, neither moves. That presents as a file that loads, shows one
+242    ///     frame, and then sits silent.
+243    ///   - Rendering on *every* frame-clock tick regardless is the opposite
+244    ///     error: `report_swap` then claims a presentation far more often than
+245    ///     real frames exist, mpv has nothing coherent to time against, and
+246    ///     playback judders badly.
+247    ///
+248    /// Polling is neither. It runs on the main thread, costs a single atomic
+249    /// read inside mpv, and answers the only question that matters.
+250    ///
+251    /// TRACES: UR-080 | DR-233
+252    pub unsafe fn has_frame(&self) -> bool {
+253        let flags = libmpv_sys::mpv_render_context_update(self.ctx);
+254        (flags & libmpv_sys::mpv_render_update_flag_MPV_RENDER_UPDATE_FRAME as u64) != 0
+255    }
+256
+257    /// Render the current frame at `width` x `height`, returning the texture id
+258    /// holding it. The GL context must be current.
+259    ///
+260    /// TRACES: UR-080 | DR-231
+261    pub unsafe fn render(&mut self, width: i32, height: i32) -> Option<u32> {
+262        if width <= 0 || height <= 0 {
+263            return None;
+264        }
+265        self.ensure_target(width, height)?;
+266        let target = self.target.as_ref()?;
+267
+268        let mut fbo = libmpv_sys::mpv_opengl_fbo {
+269            fbo: target.fbo as c_int,
+270            w: width as c_int,
+271            h: height as c_int,
+272            internal_format: 0,
+273        };
+274        // GTK's cairo surface has its origin at the top left; mpv defaults to
+275        // OpenGL's bottom-left. Without this the picture is drawn upside down —
+276        // which looks like a broken decode rather than a coordinate convention.
+277        let mut flip: c_int = 1;
+278
+279        let mut params = [
+280            libmpv_sys::mpv_render_param {
+281                type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_FBO,
+282                data: &mut fbo as *mut _ as *mut c_void,
+283            },
+284            libmpv_sys::mpv_render_param {
+285                type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_FLIP_Y,
+286                data: &mut flip as *mut _ as *mut c_void,
+287            },
+288            libmpv_sys::mpv_render_param {
+289                type_: 0,
+290                data: ptr::null_mut(),
+291            },
+292        ];
+293
+294        let rc = libmpv_sys::mpv_render_context_render(self.ctx, params.as_mut_ptr());
+295        if rc < 0 {
+296            warn!("[MpvRender] render failed: {rc}");
+297            return None;
+298        }
+299        Some(target.texture)
+300    }
+301
+302    /// Tell mpv the frame reached the screen. See [`set_update_callback`].
+303    ///
+304    /// TRACES: UR-080 | DR-233
+305    pub unsafe fn report_swap(&self) {
+306        libmpv_sys::mpv_render_context_report_swap(self.ctx);
+307    }
+308
+309    /// Create or resize the framebuffer. Reused across frames — reallocating per
+310    /// frame would churn GPU memory at the display rate.
+311    unsafe fn ensure_target(&mut self, width: i32, height: i32) -> Option<()> {
+312        if let Some(t) = &self.target {
+313            if t.width == width && t.height == height {
+314                return Some(());
+315            }
+316        }
+317        self.drop_target();
+318
+319        let gl = &self.gl;
+320        let mut texture: u32 = 0;
+321        (gl.gen_textures)(1, &mut texture);
+322        (gl.bind_texture)(GL_TEXTURE_2D, texture);
+323        (gl.tex_image_2d)(
+324            GL_TEXTURE_2D,
+325            0,
+326            GL_RGBA8,
+327            width,
+328            height,
+329            0,
+330            GL_RGBA,
+331            GL_UNSIGNED_BYTE,
+332            ptr::null(),
+333        );
+334        (gl.tex_parameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+335        (gl.tex_parameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+336        (gl.bind_texture)(GL_TEXTURE_2D, 0);
+337
+338        let mut fbo: u32 = 0;
+339        (gl.gen_framebuffers)(1, &mut fbo);
+340        (gl.bind_framebuffer)(GL_FRAMEBUFFER, fbo);
+341        (gl.framebuffer_texture_2d)(
+342            GL_FRAMEBUFFER,
+343            GL_COLOR_ATTACHMENT0,
+344            GL_TEXTURE_2D,
+345            texture,
+346            0,
+347        );
+348        let status = (gl.check_framebuffer_status)(GL_FRAMEBUFFER);
+349        (gl.bind_framebuffer)(GL_FRAMEBUFFER, 0);
+350
+351        if status != GL_FRAMEBUFFER_COMPLETE {
+352            error!("[MpvRender] framebuffer incomplete: 0x{status:x}");
+353            (gl.delete_framebuffers)(1, &fbo);
+354            (gl.delete_textures)(1, &texture);
+355            return None;
+356        }
+357
+358        self.target = Some(Target {
+359            fbo,
+360            texture,
+361            width,
+362            height,
+363        });
+364        Some(())
+365    }
+366
+367    unsafe fn drop_target(&mut self) {
+368        if let Some(t) = self.target.take() {
+369            (self.gl.delete_framebuffers)(1, &t.fbo);
+370            (self.gl.delete_textures)(1, &t.texture);
+371        }
+372    }
+373
+374    /// Free everything, with the GL context current.
+375    ///
+376    /// Explicit rather than left to `Drop` because the ordering matters and the
+377    /// caller is the only one that can guarantee the GL context is current. See
+378    /// DR-232.
+379    pub unsafe fn destroy(mut self) {
+380        // Unregister first: a callback arriving after the free would be a use
+381        // after free, and it is scheduled from mpv's own threads.
+382        libmpv_sys::mpv_render_context_set_update_callback(self.ctx, None, ptr::null_mut());
+383        self.drop_target();
+384        libmpv_sys::mpv_render_context_free(self.ctx);
+385        self.ctx = ptr::null_mut();
+386        info!("[MpvRender] render context freed");
+387        std::mem::forget(self);
+388    }
+389}
+390
+391impl Drop for MpvRenderContext {
+392    fn drop(&mut self) {
+393        if !self.ctx.is_null() {
+394            // Reached only if `destroy` was not called — the GL context may not
+395            // be current, so the GL objects are deliberately leaked rather than
+396            // deleted against whatever context happens to be bound. Freeing the
+397            // render context is still safe and is the part that matters.
+398            warn!("[MpvRender] dropped without destroy(); GL objects leaked deliberately");
+399            unsafe {
+400                libmpv_sys::mpv_render_context_set_update_callback(self.ctx, None, ptr::null_mut());
+401                libmpv_sys::mpv_render_context_free(self.ctx);
+402            }
+403        }
+404    }
+405}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/native_video.rs.html b/api/src/jellytau_lib/player/native_video.rs.html new file mode 100644 index 00000000..aa94ed8e --- /dev/null +++ b/api/src/jellytau_lib/player/native_video.rs.html @@ -0,0 +1,79 @@ +native_video.rs - sourceSkip to main content

jellytau_lib/player/
native_video.rs

1//! Whether this process renders video natively, answered once.
+2//!
+3//! Three things need this and must agree: the mpv backend (which has to be
+4//! configured for video *at construction*, before anything plays), the video
+5//! surface (which has nothing to draw otherwise), and `get_player_status`
+6//! (which tells the frontend whether to use a webview `<video>` element).
+7//!
+8//! It is a function rather than three `env::var` checks for the reason this
+9//! codebase keeps rediscovering: a capability answered in several places is a
+10//! capability whose answers drift. Four separate bugs this cycle came from
+11//! exactly that shape — a webview's decode limits applied to ExoPlayer, a
+12//! transcode target contradicting a direct-play claim, a codec list hardcoded in
+13//! a URL builder. One source, read by everyone.
+14//!
+15//! TRACES: UR-080 | DR-231, DR-235
+16
+17/// The opt-in for native desktop video.
+18///
+19/// Off by default while the render path is unproven — the webview path still
+20/// works and is what ships. This becomes the *default* (and then the only path)
+21/// when DR-235 lands; the variable is how it is exercised until then.
+22const ENV_FLAG: &str = "JELLYTAU_NATIVE_VIDEO";
+23
+24/// Whether mpv should decode and draw video in this process.
+25///
+26/// Read fresh rather than cached: it is consulted a handful of times at startup,
+27/// and a `OnceLock` here would only make it harder to test.
+28///
+29/// TRACES: UR-080 | DR-231, DR-235
+30pub fn enabled() -> bool {
+31    // Only where a native renderer exists. On Android ExoPlayer already does
+32    // this and `use_html5_element` is false for entirely separate reasons.
+33    if !cfg!(all(target_os = "linux", not(target_os = "android"))) {
+34        return false;
+35    }
+36    matches!(
+37        std::env::var(ENV_FLAG).as_deref(),
+38        Ok("1") | Ok("true") | Ok("yes")
+39    )
+40}
+41
+42#[cfg(test)]
+43mod tests {
+44    use super::*;
+45
+46    /// Absent, empty, or anything unrecognised means off. A half-set variable
+47    /// must not half-enable a renderer — the failure mode would be mpv
+48    /// configured for video with nothing drawing it, i.e. audio playing over a
+49    /// black rectangle.
+50    ///
+51    /// TRACES: UR-080 | DR-231 | UT-216
+52    #[test]
+53    fn test_only_explicit_truthy_values_enable_it() {
+54        let restore = std::env::var(ENV_FLAG).ok();
+55
+56        for value in ["", "0", "no", "false", "maybe", "2"] {
+57            std::env::set_var(ENV_FLAG, value);
+58            assert!(!enabled(), "{value:?} must not enable native video");
+59        }
+60
+61        for value in ["1", "true", "yes"] {
+62            std::env::set_var(ENV_FLAG, value);
+63            assert_eq!(
+64                enabled(),
+65                cfg!(all(target_os = "linux", not(target_os = "android"))),
+66                "{value:?} enables it exactly where a native renderer exists"
+67            );
+68        }
+69
+70        std::env::remove_var(ENV_FLAG);
+71        assert!(!enabled(), "absent means off");
+72
+73        match restore {
+74            Some(v) => std::env::set_var(ENV_FLAG, v),
+75            None => std::env::remove_var(ENV_FLAG),
+76        }
+77    }
+78}
+
\ 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..b962942d --- /dev/null +++ b/api/src/jellytau_lib/player/queue.rs.html @@ -0,0 +1,1019 @@ +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                // Audio and direct-URL items never negotiate a transport.
+545                transport: None,
+546                id: format!("item_{}", i),
+547                title: format!("Track {}", i + 1),
+548                name: Some(format!("Track {}", i + 1)),
+549                artist: Some("Artist".to_string()),
+550                album: None,
+551                album_name: None,
+552                album_id: None,
+553                artist_items: None,
+554                artists: Some(vec!["Artist".to_string()]),
+555                primary_image_tag: None,
+556                image_id: None,
+557                item_type: Some("Audio".to_string()),
+558                playlist_id: None,
+559                duration: Some(180.0),
+560                artwork_url: None,
+561                media_type: MediaType::Audio,
+562                source: MediaSource::DirectUrl {
+563                    url: format!("http://example.com/track_{}.mp3", i),
+564                },
+565                video_codec: None,
+566                needs_transcoding: false,
+567                video_width: None,
+568                video_height: None,
+569                subtitles: vec![],
+570                series_id: None,
+571                server_id: None,
+572            })
+573            .collect()
+574    }
+575
+576    /// Test setting up the queue with items
+577    ///
+578    /// @req-test: UR-015 - View and manage audio queue (add tracks)
+579    /// @req-test: DR-005 - Queue manager with shuffle, repeat, history
+580    #[test]
+581    fn test_set_queue() {
+582        let mut queue = QueueManager::new();
+583        let items = create_test_items(5);
+584
+585        queue.set_queue(items.clone(), 0);
+586
+587        assert_eq!(queue.items().len(), 5);
+588        assert_eq!(queue.current_index(), Some(0));
+589        assert_eq!(queue.current().unwrap().id, "item_0");
+590    }
+591
+592    /// Test clearing the queue returns it to the empty state so the frontend
+593    /// hides the mini player on a genuine stop.
+594    #[test]
+595    fn test_clear() {
+596        let mut queue = QueueManager::new();
+597        queue.set_queue(create_test_items(3), 1);
+598        assert_eq!(queue.current_index(), Some(1));
+599
+600        queue.clear();
+601
+602        assert_eq!(queue.items().len(), 0);
+603        assert_eq!(queue.current_index(), None);
+604        assert!(queue.current().is_none());
+605    }
+606
+607    /// Test next track navigation
+608    ///
+609    /// @req-test: UR-005 - Control media playback (skip to next track)
+610    /// @req-test: DR-005 - Queue manager with shuffle, repeat, history
+611    #[test]
+612    fn test_next() {
+613        let mut queue = QueueManager::new();
+614        queue.set_queue(create_test_items(3), 0);
+615
+616        assert_eq!(queue.current().unwrap().id, "item_0");
+617
+618        queue.next();
+619        assert_eq!(queue.current().unwrap().id, "item_1");
+620
+621        queue.next();
+622        assert_eq!(queue.current().unwrap().id, "item_2");
+623
+624        // No next without repeat
+625        assert!(queue.next().is_none());
+626    }
+627
+628    /// Test repeat all mode wraps to beginning
+629    ///
+630    /// @req-test: UR-005 - Control media playback (repeat all mode)
+631    /// @req-test: DR-005 - Queue manager with repeat
+632    #[test]
+633    fn test_repeat_all() {
+634        let mut queue = QueueManager::new();
+635        queue.set_queue(create_test_items(2), 0);
+636        queue.set_repeat(RepeatMode::All);
+637
+638        queue.next(); // Move to item_1
+639        let next = queue.next(); // Should wrap to item_0
+640
+641        assert!(next.is_some());
+642        assert_eq!(queue.current().unwrap().id, "item_0");
+643    }
+644
+645    /// Test previous track navigation
+646    ///
+647    /// @req-test: UR-005 - Control media playback (previous track)
+648    /// @req-test: DR-005 - Queue manager
+649    #[test]
+650    fn test_previous() {
+651        let mut queue = QueueManager::new();
+652        queue.set_queue(create_test_items(3), 2);
+653
+654        queue.previous();
+655        assert_eq!(queue.current_index(), Some(1));
+656    }
+657
+658    /// Test viewing upcoming tracks in queue
+659    ///
+660    /// @req-test: UR-015 - View and manage audio queue
+661    /// @req-test: DR-020 - Queue management UI (upcoming tracks)
+662    #[test]
+663    fn test_get_upcoming() {
+664        let mut queue = QueueManager::new();
+665        queue.set_queue(create_test_items(5), 0);
+666
+667        // Get next 3 items
+668        let upcoming = queue.get_upcoming(3);
+669        assert_eq!(upcoming.len(), 3);
+670        assert_eq!(upcoming[0].id, "item_1");
+671        assert_eq!(upcoming[1].id, "item_2");
+672        assert_eq!(upcoming[2].id, "item_3");
+673
+674        // Move to item 2 and get upcoming
+675        queue.next();
+676        queue.next();
+677        let upcoming = queue.get_upcoming(3);
+678        assert_eq!(upcoming.len(), 2); // Only 2 items remaining
+679        assert_eq!(upcoming[0].id, "item_3");
+680        assert_eq!(upcoming[1].id, "item_4");
+681    }
+682
+683    /// Test upcoming tracks with repeat all mode
+684    ///
+685    /// @req-test: UR-015 - View and manage audio queue
+686    /// @req-test: DR-005 - Queue manager with repeat
+687    #[test]
+688    fn test_get_upcoming_with_repeat() {
+689        let mut queue = QueueManager::new();
+690        queue.set_queue(create_test_items(3), 1);
+691        queue.set_repeat(RepeatMode::All);
+692
+693        // At item_1, get upcoming with repeat
+694        let upcoming = queue.get_upcoming(4);
+695        assert_eq!(upcoming.len(), 4);
+696        assert_eq!(upcoming[0].id, "item_2"); // Next
+697        assert_eq!(upcoming[1].id, "item_0"); // Wrapped
+698        assert_eq!(upcoming[2].id, "item_1"); // Wrapped (current again)
+699        assert_eq!(upcoming[3].id, "item_2"); // Wrapped
+700    }
+701
+702    /// Test upcoming tracks on empty queue
+703    ///
+704    /// @req-test: DR-005 - Queue manager (edge case: empty queue)
+705    #[test]
+706    fn test_get_upcoming_empty() {
+707        let queue = QueueManager::new();
+708        let upcoming = queue.get_upcoming(3);
+709        assert!(upcoming.is_empty());
+710    }
+711
+712    /// Test next stops at end without repeat mode
+713    ///
+714    /// @req-test: UR-005 - Control media playback (queue end behavior)
+715    /// @req-test: DR-005 - Queue manager
+716    #[test]
+717    fn test_next_no_repeat_reaches_end() {
+718        let mut queue = QueueManager::new();
+719        queue.set_queue(create_test_items(3), 0);
+720        queue.set_repeat(RepeatMode::Off);
+721
+722        // At item 0, should have next
+723        assert!(queue.has_next());
+724        assert_eq!(queue.current_index(), Some(0));
+725
+726        // Move to item 1
+727        queue.next();
+728        assert!(queue.has_next());
+729        assert_eq!(queue.current_index(), Some(1));
+730
+731        // Move to item 2 (last)
+732        queue.next();
+733        assert!(!queue.has_next()); // No more items
+734        assert_eq!(queue.current_index(), Some(2));
+735
+736        // Try to move past end
+737        let result = queue.next();
+738        assert!(result.is_none());
+739        assert_eq!(queue.current_index(), Some(2)); // Should stay at last item
+740    }
+741
+742    /// Test next wraps to beginning with repeat all
+743    ///
+744    /// @req-test: UR-005 - Control media playback (repeat all wrapping)
+745    /// @req-test: DR-005 - Queue manager with repeat
+746    #[test]
+747    fn test_next_repeat_all_wraps() {
+748        let mut queue = QueueManager::new();
+749        queue.set_queue(create_test_items(3), 0);
+750        queue.set_repeat(RepeatMode::All);
+751
+752        // With repeat all, has_next should always be true
+753        assert!(queue.has_next());
+754
+755        // Move through all items
+756        queue.next(); // item_1
+757        assert!(queue.has_next());
+758        queue.next(); // item_2
+759        assert!(queue.has_next());
+760
+761        // Wrap to beginning
+762        let result = queue.next();
+763        assert!(result.is_some());
+764        assert_eq!(queue.current().unwrap().id, "item_0");
+765        assert!(queue.has_next()); // Still has next (loops forever)
+766    }
+767
+768    /// Test next repeats same track with repeat one mode
+769    ///
+770    /// @req-test: UR-005 - Control media playback (repeat one mode)
+771    /// @req-test: DR-005 - Queue manager with repeat
+772    #[test]
+773    fn test_next_repeat_one_stays() {
+774        let mut queue = QueueManager::new();
+775        queue.set_queue(create_test_items(3), 1);
+776        queue.set_repeat(RepeatMode::One);
+777
+778        // Should always have next (repeats current)
+779        assert!(queue.has_next());
+780        assert_eq!(queue.current_index(), Some(1));
+781
+782        // Call next multiple times - should stay on same track
+783        for _ in 0..5 {
+784            let result = queue.next();
+785            assert!(result.is_some());
+786            assert_eq!(queue.current().unwrap().id, "item_1");
+787            assert_eq!(queue.current_index(), Some(1));
+788            assert!(queue.has_next());
+789        }
+790    }
+791
+792    /// Test shuffle mode follows randomized order
+793    ///
+794    /// @req-test: UR-005 - Control media playback (shuffle mode)
+795    /// @req-test: DR-005 - Queue manager with shuffle
+796    #[test]
+797    fn test_next_shuffle_follows_order() {
+798        let mut queue = QueueManager::new();
+799        queue.set_queue(create_test_items(4), 0);
+800        queue.toggle_shuffle(); // Enable shuffle
+801
+802        // Get the shuffle order for verification
+803        let shuffle_order = queue.shuffle_order.clone();
+804        assert_eq!(shuffle_order.len(), 4);
+805
+806        // Current should be first item in shuffle order
+807        let first_shuffled_index = shuffle_order[0];
+808        assert_eq!(queue.current_index(), Some(first_shuffled_index));
+809
+810        // Move through shuffle order
+811        for &expected_index in &shuffle_order[1..] {
+812            assert!(queue.has_next());
+813            let result = queue.next();
+814            assert!(result.is_some());
+815            assert_eq!(queue.current_index(), Some(expected_index));
+816        }
+817
+818        // At end of shuffle without repeat
+819        assert!(!queue.has_next());
+820        let result = queue.next();
+821        assert!(result.is_none());
+822    }
+823
+824    /// Test shuffle with repeat all wraps shuffle order
+825    ///
+826    /// @req-test: UR-005 - Control media playback (shuffle + repeat)
+827    /// @req-test: DR-005 - Queue manager with shuffle and repeat
+828    #[test]
+829    fn test_next_shuffle_with_repeat_all() {
+830        let mut queue = QueueManager::new();
+831        queue.set_queue(create_test_items(3), 0);
+832        queue.toggle_shuffle(); // Enable shuffle
+833        queue.set_repeat(RepeatMode::All);
+834
+835        let shuffle_order = queue.shuffle_order.clone();
+836
+837        // Move through entire shuffle order
+838        for _ in 1..shuffle_order.len() {
+839            queue.next();
+840        }
+841
+842        // At end, should wrap to beginning of shuffle order
+843        assert!(queue.has_next());
+844        let result = queue.next();
+845        assert!(result.is_some());
+846        assert_eq!(queue.current_index(), Some(shuffle_order[0]));
+847    }
+848
+849    /// Test has_next logic accuracy across different scenarios
+850    ///
+851    /// @req-test: DR-005 - Queue manager (has_next accuracy)
+852    /// @req-test: UR-015 - View and manage audio queue
+853    #[test]
+854    fn test_has_next_accuracy() {
+855        // Test 1: Empty queue
+856        let queue = QueueManager::new();
+857        assert!(!queue.has_next());
+858
+859        // Test 2: Last track with repeat off
+860        let mut queue = QueueManager::new();
+861        queue.set_queue(create_test_items(2), 1); // Start at last item
+862        queue.set_repeat(RepeatMode::Off);
+863        assert!(!queue.has_next());
+864
+865        // Test 3: Last track with repeat all
+866        let mut queue = QueueManager::new();
+867        queue.set_queue(create_test_items(2), 1); // Start at last item
+868        queue.set_repeat(RepeatMode::All);
+869        assert!(queue.has_next()); // Should wrap
+870
+871        // Test 4: Repeat one mode (always has next)
+872        let mut queue = QueueManager::new();
+873        queue.set_queue(create_test_items(1), 0);
+874        queue.set_repeat(RepeatMode::One);
+875        assert!(queue.has_next()); // Repeats forever
+876
+877        // Test 5: Middle of queue with repeat off
+878        let mut queue = QueueManager::new();
+879        queue.set_queue(create_test_items(3), 1); // Middle item
+880        queue.set_repeat(RepeatMode::Off);
+881        assert!(queue.has_next()); // Has item_2 next
+882
+883        // Test 6: Shuffle at end without repeat
+884        let mut queue = QueueManager::new();
+885        queue.set_queue(create_test_items(3), 0);
+886        queue.toggle_shuffle(); // Enable shuffle
+887        queue.set_repeat(RepeatMode::Off);
+888        // Move to last item in shuffle order
+889        let shuffle_len = queue.shuffle_order.len();
+890        for _ in 1..shuffle_len {
+891            queue.next();
+892        }
+893        assert!(!queue.has_next());
+894    }
+895
+896    /// Test has_next on empty queue edge case
+897    ///
+898    /// @req-test: DR-005 - Queue manager (edge case: empty queue)
+899    #[test]
+900    fn test_has_next_empty_queue() {
+901        let queue = QueueManager::new();
+902        assert!(!queue.has_next());
+903        assert_eq!(queue.current_index(), None);
+904    }
+905
+906    /// Test that selecting a specific track in an album starts at the correct index
+907    ///
+908    /// Reproduces the bug where clicking songs 1-5 always played song 13
+909    #[test]
+910    fn test_play_specific_track_from_album() {
+911        let mut queue = QueueManager::new();
+912        let items = create_test_items(14); // 14-track album
+913
+914        // Simulate playing track 0 (first track)
+915        queue.set_queue(items.clone(), 0);
+916        assert_eq!(queue.current_index(), Some(0));
+917        assert_eq!(queue.current().unwrap().id, "item_0");
+918
+919        // Simulate playing track 3 (fourth track)
+920        queue.set_queue(items.clone(), 3);
+921        assert_eq!(queue.current_index(), Some(3));
+922        assert_eq!(queue.current().unwrap().id, "item_3");
+923
+924        // Simulate playing track 13 (last track)
+925        queue.set_queue(items, 13);
+926        assert_eq!(queue.current_index(), Some(13));
+927        assert_eq!(queue.current().unwrap().id, "item_13");
+928    }
+929
+930    /// Test that next() then previous() returns to the original track
+931    #[test]
+932    fn test_next_previous_roundtrip() {
+933        let mut queue = QueueManager::new();
+934        queue.set_queue(create_test_items(5), 2); // Start at middle track
+935
+936        assert_eq!(queue.current_index(), Some(2));
+937
+938        // Go to next track
+939        queue.next();
+940        assert_eq!(queue.current_index(), Some(3));
+941
+942        // Go back - should return to track 2
+943        queue.previous();
+944        assert_eq!(queue.current_index(), Some(2));
+945        assert_eq!(queue.current().unwrap().id, "item_2");
+946    }
+947
+948    /// Test that previous() at the first track stays at first track
+949    #[test]
+950    fn test_previous_at_first_track_stays() {
+951        let mut queue = QueueManager::new();
+952        queue.set_queue(create_test_items(5), 0); // Start at first track
+953
+954        assert_eq!(queue.current_index(), Some(0));
+955
+956        // Try to go to previous - should stay at 0
+957        let result = queue.previous();
+958        assert!(result.is_none());
+959        assert_eq!(queue.current_index(), Some(0));
+960        assert_eq!(queue.current().unwrap().id, "item_0");
+961    }
+962
+963    /// Test that next() at last track (no repeat) stays at last track
+964    #[test]
+965    fn test_next_at_last_track_stays() {
+966        let mut queue = QueueManager::new();
+967        queue.set_queue(create_test_items(5), 4); // Start at last track
+968
+969        assert_eq!(queue.current_index(), Some(4));
+970
+971        // Try to go to next - should return None and stay at 4
+972        let result = queue.next();
+973        assert!(result.is_none());
+974        assert_eq!(queue.current_index(), Some(4));
+975        assert_eq!(queue.current().unwrap().id, "item_4");
+976    }
+977
+978    /// Test history validation prevents invalid wraparound
+979    #[test]
+980    fn test_history_validation_prevents_wraparound() {
+981        let mut queue = QueueManager::new();
+982        queue.set_queue(create_test_items(5), 0);
+983
+984        // Manually corrupt history to simulate the bug
+985        // (In the real bug, history would have last track's index)
+986        queue.history.push(4);
+987
+988        // Try to go previous - should detect invalid history and clear it
+989        let result = queue.previous();
+990        assert!(result.is_none()); // Should not wrap to track 4
+991        assert_eq!(queue.current_index(), Some(0)); // Should stay at track 0
+992        assert!(queue.history.is_empty()); // History should be cleared
+993    }
+994
+995    /// Test multiple next() calls build correct history
+996    #[test]
+997    fn test_multiple_next_builds_history() {
+998        let mut queue = QueueManager::new();
+999        queue.set_queue(create_test_items(5), 0);
+1000
+1001        // Navigate: 0 -> 1 -> 2 -> 3
+1002        queue.next(); // Now at 1, history=[0]
+1003        queue.next(); // Now at 2, history=[0, 1]
+1004        queue.next(); // Now at 3, history=[0, 1, 2]
+1005
+1006        assert_eq!(queue.current_index(), Some(3));
+1007
+1008        // Go back through history: 3 -> 2 -> 1 -> 0
+1009        queue.previous();
+1010        assert_eq!(queue.current_index(), Some(2));
+1011
+1012        queue.previous();
+1013        assert_eq!(queue.current_index(), Some(1));
+1014
+1015        queue.previous();
+1016        assert_eq!(queue.current_index(), Some(0));
+1017    }
+1018}
+
\ 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..35ba6744 --- /dev/null +++ b/api/src/jellytau_lib/player/seek.rs.html @@ -0,0 +1,338 @@ +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/// * `seeks_transcoded_in_place` - Whether the engine rendering this stream
+29///   can seek a server-side transcode without re-opening it. Declared by the
+30///   engine via `Capabilities`, never inferred from the URL or the renderer.
+31/// * `needs_transcoding` - Whether the content needs transcoding
+32/// * `use_html5` - Whether frontend is using HTML5 video element
+33pub fn determine_video_seek_strategy(
+34    is_local: bool,
+35    seeks_transcoded_in_place: bool,
+36    needs_transcoding: bool,
+37    use_html5: bool,
+38) -> VideoSeekStrategy {
+39    // Local files always support native seeking via backend
+40    if is_local {
+41        return VideoSeekStrategy::LocalNativeSeek;
+42    }
+43
+44    // A server-side transcode is produced *from* `StartTimeTicks`, so where the
+45    // seek lands is a property of the request, not of the stream in hand.
+46    //
+47    // hls.js is the exception: handed a VOD playlist it seeks within it and lets
+48    // the server catch up segment by segment. mpv's HLS demuxer cannot make
+49    // Jellyfin transcode from a new offset, so for the native backend a
+50    // transcoded seek must re-negotiate the stream regardless of container.
+51    //
+52    // Before native video shipped, `use_html5` was always true for HLS and the
+53    // native+HLS+transcode cell was unreachable, which is why `is_hls` alone
+54    // used to be a safe proxy for "seekable in place". It no longer is: turning
+55    // native video on routed every transcoded seek into a backend seek that
+56    // silently does nothing, and presents as "resume does not work".
+57    if needs_transcoding {
+58        // Whether a transcode can be seeked in place is a property of the
+59        // engine, and the engine states it. This used to be inferred from
+60        // `is_hls`, which held only while hls.js was the sole HLS renderer —
+61        // and stopped holding the moment mpv became one (DR-238).
+62        return match (seeks_transcoded_in_place, use_html5) {
+63            (true, true) => VideoSeekStrategy::Html5NativeSeek,
+64            (true, false) => VideoSeekStrategy::BackendNativeSeek,
+65            (false, true) => VideoSeekStrategy::Html5ReloadStream,
+66            (false, false) => VideoSeekStrategy::BackendReloadStream,
+67        };
+68    }
+69
+70    // Direct play and direct stream are seekable where they sit.
+71    if use_html5 {
+72        // The frontend seeks via videoElement.currentTime; calling backend.seek()
+73        // would move a player that is not the one rendering.
+74        VideoSeekStrategy::Html5NativeSeek
+75    } else {
+76        VideoSeekStrategy::BackendNativeSeek
+77    }
+78}
+79
+80// The four items below are consumed by the Android MediaSessionHandler; on other
+81// targets only the tests exercise them, so dead-code analysis would flag them.
+82
+83/// How far a lockscreen skip-forward jumps while background audio owns playback.
+84#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+85pub const SKIP_FORWARD_SECONDS: f64 = 30.0;
+86
+87/// How far a lockscreen skip-back jumps while background audio owns playback.
+88///
+89/// Deliberately shorter than the forward jump: the back button is used to replay
+90/// dialogue just missed, not to travel.
+91#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+92pub const SKIP_BACK_SECONDS: f64 = 10.0;
+93
+94/// What a lockscreen skip button means for the playback that is actually running.
+95#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+96#[derive(Debug, Clone, Copy, PartialEq)]
+97pub enum SkipAction {
+98    /// Move to the next/previous queue entry — a track, or an episode.
+99    Advance,
+100    /// Scrub within the current item, to this absolute position in seconds.
+101    SeekTo(f64),
+102}
+103
+104/// Decide whether a lockscreen skip advances the queue or scrubs the current item.
+105///
+106/// Music gets queue advance, which is what the buttons look like they do. A video
+107/// whose audio is playing through a background-audio handoff (UR-040) gets a
+108/// relative scrub instead: there is no meaningful "next track" inside a film, and
+109/// jumping to the next *episode* because the user wanted to re-hear a line is a
+110/// much worse outcome than a scrub.
+111///
+112/// `is_background_audio` is the whole test, and it is sufficient on its own —
+113/// the handoff exists only for video, and an episode played through it reports
+114/// `MediaType::Audio`, so media type cannot distinguish this case (see the note
+115/// at `PlayerController::auto_advance_to_next_episode`).
+116///
+117/// Clamped to `[0, duration]` so a skip near either end lands in the item rather
+118/// than at a negative offset or past the end, which some backends treat as EOF
+119/// and would turn a scrub into an unintended advance.
+120///
+121/// TRACES: UR-040, UR-006 | DR-201
+122#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+123pub fn resolve_skip_action(
+124    is_next: bool,
+125    is_background_audio: bool,
+126    position: f64,
+127    duration: Option<f64>,
+128) -> SkipAction {
+129    if !is_background_audio {
+130        return SkipAction::Advance;
+131    }
+132
+133    let target = if is_next {
+134        position + SKIP_FORWARD_SECONDS
+135    } else {
+136        position - SKIP_BACK_SECONDS
+137    };
+138
+139    let clamped = match duration {
+140        Some(d) if d > 0.0 => target.clamp(0.0, d),
+141        _ => target.max(0.0),
+142    };
+143
+144    SkipAction::SeekTo(clamped)
+145}
+146
+147#[cfg(test)]
+148mod tests {
+149    use super::*;
+150
+151    /// Music (no background-audio handoff) keeps queue advance on both buttons.
+152    ///
+153    /// TRACES: UR-006 | DR-201 | UT-194
+154    #[test]
+155    fn test_skip_advances_queue_for_normal_audio() {
+156        assert_eq!(
+157            resolve_skip_action(true, false, 42.0, Some(300.0)),
+158            SkipAction::Advance
+159        );
+160        assert_eq!(
+161            resolve_skip_action(false, false, 42.0, Some(300.0)),
+162            SkipAction::Advance
+163        );
+164    }
+165
+166    /// The reported bug: in background-audio mode the lockscreen skip buttons
+167    /// advanced to the next/previous episode instead of scrubbing, so trying to
+168    /// re-hear a line jumped out of the film entirely.
+169    ///
+170    /// TRACES: UR-040 | DR-201 | UT-195
+171    #[test]
+172    fn test_skip_scrubs_in_background_audio_mode() {
+173        assert_eq!(
+174            resolve_skip_action(true, true, 100.0, Some(3600.0)),
+175            SkipAction::SeekTo(130.0)
+176        );
+177        assert_eq!(
+178            resolve_skip_action(false, true, 100.0, Some(3600.0)),
+179            SkipAction::SeekTo(90.0)
+180        );
+181    }
+182
+183    /// Skipping back near the start clamps to zero rather than going negative,
+184    /// which backends reject (the "Raw(-10)" class of error).
+185    ///
+186    /// TRACES: UR-040 | DR-201 | UT-196
+187    #[test]
+188    fn test_skip_back_clamps_at_start() {
+189        assert_eq!(
+190            resolve_skip_action(false, true, 4.0, Some(3600.0)),
+191            SkipAction::SeekTo(0.0)
+192        );
+193    }
+194
+195    /// Skipping forward near the end clamps to the duration instead of running
+196    /// past it, which would read as end-of-stream and advance — the very thing
+197    /// this function exists to prevent.
+198    ///
+199    /// TRACES: UR-040 | DR-201 | UT-197
+200    #[test]
+201    fn test_skip_forward_clamps_at_end() {
+202        assert_eq!(
+203            resolve_skip_action(true, true, 3590.0, Some(3600.0)),
+204            SkipAction::SeekTo(3600.0)
+205        );
+206    }
+207
+208    /// An unknown duration still scrubs, and still refuses to go negative.
+209    ///
+210    /// TRACES: UR-040 | DR-201 | UT-198
+211    #[test]
+212    fn test_skip_without_duration_still_scrubs() {
+213        assert_eq!(
+214            resolve_skip_action(true, true, 10.0, None),
+215            SkipAction::SeekTo(40.0)
+216        );
+217        assert_eq!(
+218            resolve_skip_action(false, true, 3.0, None),
+219            SkipAction::SeekTo(0.0)
+220        );
+221    }
+222
+223    /// Test video seek strategy for local files
+224    #[test]
+225    fn test_seek_strategy_local_file() {
+226        // Local files always use native backend seek regardless of other flags
+227        assert_eq!(
+228            determine_video_seek_strategy(true, false, false, false),
+229            VideoSeekStrategy::LocalNativeSeek
+230        );
+231        assert_eq!(
+232            determine_video_seek_strategy(true, false, false, true),
+233            VideoSeekStrategy::LocalNativeSeek
+234        );
+235        assert_eq!(
+236            determine_video_seek_strategy(true, true, true, true),
+237            VideoSeekStrategy::LocalNativeSeek
+238        );
+239    }
+240
+241    /// Non-transcoded streams seek in place regardless of the engine's
+242    /// transcode ability, which only applies to transcodes.
+243    #[test]
+244    fn test_seek_strategy_direct_stream() {
+245        // HTML5 renders, so the frontend seeks the element
+246        assert_eq!(
+247            determine_video_seek_strategy(false, true, false, true),
+248            VideoSeekStrategy::Html5NativeSeek
+249        );
+250        // The native engine renders, so it seeks
+251        assert_eq!(
+252            determine_video_seek_strategy(false, true, false, false),
+253            VideoSeekStrategy::BackendNativeSeek
+254        );
+255        // A transcode an engine says it can move: seek in place
+256        assert_eq!(
+257            determine_video_seek_strategy(false, true, true, true),
+258            VideoSeekStrategy::Html5NativeSeek
+259        );
+260    }
+261
+262    /// A server-side transcode cannot be seeked by the native backend.
+263    ///
+264    /// Jellyfin produces a transcode from `StartTimeTicks`; hls.js can seek
+265    /// within the VOD playlist it is handed, but mpv's HLS demuxer cannot make
+266    /// the server transcode from a new offset, so the stream has to be
+267    /// re-negotiated. Before native video existed, `use_html5` was always true
+268    /// for HLS and this case was unreachable — turning native video on routed
+269    /// every transcoded seek into a native seek that silently does nothing,
+270    /// which presents as "resume does not work".
+271    ///
+272    /// TRACES: UR-040 | DR-238, DR-246 | UT-217
+273    #[test]
+274    fn test_transcoded_seek_follows_the_engines_declared_ability() {
+275        // An engine that cannot move a server-side transcode re-opens it,
+276        // whichever side is rendering.
+277        assert_eq!(
+278            determine_video_seek_strategy(false, false, true, false),
+279            VideoSeekStrategy::BackendReloadStream
+280        );
+281        assert_eq!(
+282            determine_video_seek_strategy(false, false, true, true),
+283            VideoSeekStrategy::Html5ReloadStream
+284        );
+285        // hls.js can, and says so, so it seeks in place.
+286        assert_eq!(
+287            determine_video_seek_strategy(false, true, true, true),
+288            VideoSeekStrategy::Html5NativeSeek
+289        );
+290        // The container the stream arrives in no longer decides anything: the
+291        // same declared ability gives the same answer on the native side.
+292        assert_eq!(
+293            determine_video_seek_strategy(false, true, true, false),
+294            VideoSeekStrategy::BackendNativeSeek
+295        );
+296    }
+297
+298    /// Test video seek strategy for direct play (non-transcoded) streams
+299    #[test]
+300    fn test_seek_strategy_direct_play() {
+301        // Direct play with HTML5 - frontend handles seek
+302        assert_eq!(
+303            determine_video_seek_strategy(false, false, false, true),
+304            VideoSeekStrategy::Html5NativeSeek
+305        );
+306        // Direct play with native backend - backend handles seek
+307        assert_eq!(
+308            determine_video_seek_strategy(false, false, false, false),
+309            VideoSeekStrategy::BackendNativeSeek
+310        );
+311    }
+312
+313    /// Test video seek strategy for transcoded non-HLS streams
+314    #[test]
+315    fn test_seek_strategy_transcoded_non_hls() {
+316        // Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
+317        assert_eq!(
+318            determine_video_seek_strategy(false, false, true, true),
+319            VideoSeekStrategy::Html5ReloadStream
+320        );
+321        // Transcoded non-HLS with native backend - need to reload stream, backend handles
+322        assert_eq!(
+323            determine_video_seek_strategy(false, false, true, false),
+324            VideoSeekStrategy::BackendReloadStream
+325        );
+326    }
+327
+328    /// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
+329    /// This was the bug causing "Raw(-10)" errors
+330    #[test]
+331    fn test_hls_html5_does_not_use_backend_seek() {
+332        let strategy = determine_video_seek_strategy(false, true, false, true);
+333        // Should be Html5NativeSeek, NOT BackendNativeSeek
+334        assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
+335        assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
+336    }
+337}
+
\ 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..68528ded --- /dev/null +++ b/api/src/jellytau_lib/player/session.rs.html @@ -0,0 +1,385 @@ +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            // Audio and direct-URL items never negotiate a transport.
+236            transport: None,
+237            id: title.to_string(),
+238            title: title.to_string(),
+239            name: Some(title.to_string()),
+240            artist: Some("Test Artist".to_string()),
+241            album: Some("Test Album".to_string()),
+242            album_name: Some("Test Album".to_string()),
+243            album_id: None,
+244            artist_items: None,
+245            artists: Some(vec!["Test Artist".to_string()]),
+246            primary_image_tag: None,
+247            image_id: None,
+248            item_type: Some("Audio".to_string()),
+249            playlist_id: None,
+250            duration: Some(180.0),
+251            artwork_url: None,
+252            media_type: crate::player::media::MediaType::Audio,
+253            source: MediaSource::DirectUrl {
+254                url: "http://example.com/audio.mp3".to_string(),
+255            },
+256            video_codec: None,
+257            needs_transcoding: false,
+258            video_width: None,
+259            video_height: None,
+260            subtitles: vec![],
+261            series_id: None,
+262            server_id: None,
+263        }
+264    }
+265
+266    fn create_test_movie_item(title: &str) -> MediaItem {
+267        MediaItem {
+268            // Audio and direct-URL items never negotiate a transport.
+269            transport: None,
+270            id: title.to_string(),
+271            title: title.to_string(),
+272            name: Some(title.to_string()),
+273            artist: None,
+274            album: None,
+275            album_name: None,
+276            album_id: None,
+277            artist_items: None,
+278            artists: None,
+279            primary_image_tag: None,
+280            image_id: None,
+281            item_type: Some("Movie".to_string()),
+282            playlist_id: None,
+283            duration: Some(7200.0),
+284            artwork_url: None,
+285            media_type: crate::player::media::MediaType::Video,
+286            source: MediaSource::DirectUrl {
+287                url: "http://example.com/movie.mp4".to_string(),
+288            },
+289            video_codec: None,
+290            needs_transcoding: false,
+291            video_width: None,
+292            video_height: None,
+293            subtitles: vec![],
+294            series_id: None,
+295            server_id: None,
+296        }
+297    }
+298
+299    #[test]
+300    fn test_initial_state_is_idle() {
+301        let manager = MediaSessionManager::new();
+302        assert_eq!(manager.current(), &MediaSessionType::Idle);
+303    }
+304
+305    #[test]
+306    fn test_audio_session_lifecycle() {
+307        let mut manager = MediaSessionManager::new();
+308
+309        // Start audio session
+310        let track = create_test_audio_item("Track 1");
+311        manager.start_audio_session(track.clone());
+312
+313        assert!(matches!(
+314            manager.current(),
+315            MediaSessionType::Audio {
+316                is_active: true,
+317                ..
+318            }
+319        ));
+320        assert!(manager.should_show_miniplayer());
+321
+322        // Update to next track
+323        let track2 = create_test_audio_item("Track 2");
+324        manager.update_audio_track(track2);
+325        assert!(manager.current().is_playing_or_paused());
+326
+327        // Queue ends, session goes inactive
+328        manager.audio_session_inactive();
+329        assert!(matches!(
+330            manager.current(),
+331            MediaSessionType::Audio {
+332                is_active: false,
+333                ..
+334            }
+335        ));
+336        assert!(manager.should_show_miniplayer()); // Still shows!
+337
+338        // Resume
+339        manager.resume_audio_session();
+340        assert!(manager.current().is_playing_or_paused());
+341
+342        // Dismiss
+343        manager.dismiss();
+344        assert_eq!(manager.current(), &MediaSessionType::Idle);
+345        assert!(!manager.should_show_miniplayer());
+346    }
+347
+348    #[test]
+349    fn test_movie_session_auto_dismiss() {
+350        let mut manager = MediaSessionManager::new();
+351
+352        let movie = create_test_movie_item("Test Movie");
+353        manager.start_movie_session(movie);
+354
+355        assert!(matches!(
+356            manager.current(),
+357            MediaSessionType::Movie {
+358                is_active: true,
+359                ..
+360            }
+361        ));
+362        assert!(manager.should_show_video_player());
+363
+364        // Movie ends, auto-dismiss to Idle
+365        manager.movie_session_ended();
+366        assert_eq!(manager.current(), &MediaSessionType::Idle);
+367    }
+368
+369    #[test]
+370    fn test_session_replacement() {
+371        let mut manager = MediaSessionManager::new();
+372
+373        // Start with audio
+374        let track = create_test_audio_item("Track 1");
+375        manager.start_audio_session(track);
+376        assert!(manager.should_show_miniplayer());
+377
+378        // Switch to movie (replaces audio session)
+379        let movie = create_test_movie_item("Test Movie");
+380        manager.start_movie_session(movie);
+381        assert!(!manager.should_show_miniplayer());
+382        assert!(manager.should_show_video_player());
+383    }
+384}
+
\ 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..ec5c40c8 --- /dev/null +++ b/api/src/jellytau_lib/player/state.rs.html @@ -0,0 +1,350 @@ +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            // Audio and direct-URL items never negotiate a transport.
+320            transport: None,
+321            id: id.to_string(),
+322            title: title.to_string(),
+323            name: None,
+324            artist: None,
+325            album: None,
+326            album_name: None,
+327            album_id: None,
+328            artist_items: None,
+329            artists: None,
+330            primary_image_tag: None,
+331            image_id: None,
+332            item_type: Some("Video".to_string()),
+333            playlist_id: None,
+334            duration: Some(100.0),
+335            artwork_url: None,
+336            media_type: super::super::media::MediaType::Video,
+337            source: super::super::media::MediaSource::DirectUrl {
+338                url: "http://example.com/media".to_string(),
+339            },
+340            video_codec: None,
+341            needs_transcoding: false,
+342            video_width: None,
+343            video_height: None,
+344            subtitles: vec![],
+345            series_id: None,
+346            server_id: None,
+347        }
+348    }
+349}
+
\ 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..5a97e511 --- /dev/null +++ b/api/src/jellytau_lib/player/stream_end.rs.html @@ -0,0 +1,525 @@ +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            // Audio and direct-URL items never negotiate a transport.
+262            transport: None,
+263            id: "ep2".to_string(),
+264            title: "Episode 2".to_string(),
+265            name: None,
+266            artist: None,
+267            album: None,
+268            album_name: None,
+269            album_id: None,
+270            artist_items: None,
+271            artists: None,
+272            primary_image_tag: None,
+273            image_id: None,
+274            item_type: Some("Episode".to_string()),
+275            playlist_id: None,
+276            duration: Some(1500.0),
+277            artwork_url: None,
+278            media_type: MediaType::Audio,
+279            source: MediaSource::Remote {
+280                stream_url: "http://s/Audio/ep2/universal?Container=mp3&StartTimeTicks=1250000000"
+281                    .to_string(),
+282                jellyfin_item_id: "ep2".to_string(),
+283            },
+284            video_codec: None,
+285            needs_transcoding: false,
+286            video_width: None,
+287            video_height: None,
+288            subtitles: vec![],
+289            series_id: Some("series1".to_string()),
+290            server_id: None,
+291        }
+292    }
+293
+294    /// The reported bug: a load error on the length-less handoff transcode let
+295    /// ExoPlayer "retry" the only way it can — from offset 0 — which re-opens
+296    /// the URL at its `StartTimeTicks` and drops playback back to the handoff
+297    /// point, silently. This item must never be left to the player's own retry.
+298    #[test]
+299    fn test_handoff_transcode_must_not_use_the_players_own_retry() {
+300        assert!(player_retry_restarts_stream(&handoff_item()));
+301    }
+302
+303    #[test]
+304    fn test_music_keeps_the_players_retry() {
+305        // `/Audio/{id}/stream?Static=true` — a real Content-Length and byte
+306        // ranges, so ExoPlayer resumes it where the load failed.
+307        let track = MediaItem {
+308            // Audio and direct-URL items never negotiate a transport.
+309            transport: None,
+310            item_type: Some("Audio".to_string()),
+311            ..handoff_item()
+312        };
+313        assert!(!player_retry_restarts_stream(&track));
+314    }
+315
+316    #[test]
+317    fn test_video_keeps_the_players_retry() {
+318        // An HLS playlist declares its segments, so a failed segment load is
+319        // retried at that segment, not at the start of the episode.
+320        let video = MediaItem {
+321            // Audio and direct-URL items never negotiate a transport.
+322            transport: None,
+323            media_type: MediaType::Video,
+324            ..handoff_item()
+325        };
+326        assert!(!player_retry_restarts_stream(&video));
+327    }
+328
+329    #[test]
+330    fn test_downloaded_episode_keeps_the_players_retry() {
+331        // A local file has no length problem and no network to lose.
+332        let local = MediaItem {
+333            // Audio and direct-URL items never negotiate a transport.
+334            transport: None,
+335            source: MediaSource::Local {
+336                file_path: PathBuf::from("/data/ep2.mkv"),
+337                jellyfin_item_id: Some("ep2".to_string()),
+338            },
+339            ..handoff_item()
+340        };
+341        assert!(!player_retry_restarts_stream(&local));
+342    }
+343
+344    #[test]
+345    fn test_end_near_duration_is_a_natural_finish() {
+346        // Episode runtime 25:00, stream ended at 24:56 — that is the end.
+347        assert!(!is_truncated_end(
+348            1496.0,
+349            Some(1500.0),
+350            TRUNCATED_STREAM_TOLERANCE_SECS
+351        ));
+352    }
+353
+354    #[test]
+355    fn test_end_far_short_of_duration_is_truncated() {
+356        // Episode runtime 25:00, stream died at 10:00 — the connection dropped.
+357        assert!(is_truncated_end(
+358            600.0,
+359            Some(1500.0),
+360            TRUNCATED_STREAM_TOLERANCE_SECS
+361        ));
+362    }
+363
+364    #[test]
+365    fn test_unknown_duration_is_taken_at_face_value() {
+366        // Nothing to compare against: keep the previous end-of-track behaviour
+367        // rather than resuming a stream that may really have finished.
+368        assert!(!is_truncated_end(
+369            600.0,
+370            None,
+371            TRUNCATED_STREAM_TOLERANCE_SECS
+372        ));
+373        assert!(!is_truncated_end(
+374            600.0,
+375            Some(0.0),
+376            TRUNCATED_STREAM_TOLERANCE_SECS
+377        ));
+378    }
+379
+380    #[test]
+381    fn test_tolerance_boundary() {
+382        // Exactly one tolerance short still counts as finished, so poll staleness
+383        // and runtime rounding never fabricate a truncation.
+384        assert!(!is_truncated_end(1490.0, Some(1500.0), 10.0));
+385        assert!(is_truncated_end(1489.0, Some(1500.0), 10.0));
+386    }
+387
+388    #[test]
+389    fn test_with_start_time_replaces_existing_ticks() {
+390        let url = "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=1200000000&Container=mp3";
+391        let out = with_start_time(url, 600.0);
+392        assert_eq!(
+393            out,
+394            "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=6000000000&Container=mp3"
+395        );
+396    }
+397
+398    #[test]
+399    fn test_with_start_time_appends_when_absent() {
+400        // The next-episode stream is built without StartTimeTicks.
+401        let url = "http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0";
+402        let out = with_start_time(url, 90.0);
+403        assert_eq!(
+404            out,
+405            "http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0&StartTimeTicks=900000000"
+406        );
+407    }
+408
+409    #[test]
+410    fn test_with_start_time_preserves_selected_audio_track() {
+411        // The whole point of editing the URL instead of rebuilding it: the track
+412        // the user chose in the video player survives the resume.
+413        let url = "http://s/Audio/ep2/universal?AudioStreamIndex=3&MediaSourceId=src-1";
+414        let out = with_start_time(url, 10.0);
+415        assert!(out.contains("AudioStreamIndex=3"));
+416        assert!(out.contains("MediaSourceId=src-1"));
+417    }
+418
+419    #[test]
+420    fn test_with_start_time_without_query() {
+421        assert_eq!(
+422            with_start_time("http://s/Audio/ep2/universal", 1.0),
+423            "http://s/Audio/ep2/universal?StartTimeTicks=10000000"
+424        );
+425    }
+426
+427    /// The bug: MPV unloads the file at EOF, so `time-pos` stops resolving and a
+428    /// straight read reports 0.0 — the position collapses to zero at precisely
+429    /// the moment end-of-file handling needs to know where playback reached.
+430    #[test]
+431    fn test_eof_reads_as_the_last_observed_timestamp() {
+432        let mut observed = ObservedTime::default();
+433        observed.record(178.0, 180.0);
+434
+435        // The file is gone: both live properties fail.
+436        assert_eq!(observed.position_or_last(None), 178.0);
+437        assert_eq!(observed.duration_or_last(None), Some(180.0));
+438    }
+439
+440    #[test]
+441    fn test_live_readings_win_while_the_file_is_loaded() {
+442        let mut observed = ObservedTime::default();
+443        observed.record(178.0, 180.0);
+444
+445        assert_eq!(observed.position_or_last(Some(12.0)), 12.0);
+446        assert_eq!(observed.duration_or_last(Some(240.0)), Some(240.0));
+447    }
+448
+449    #[test]
+450    fn test_unestablished_duration_is_not_recorded_as_zero() {
+451        let mut observed = ObservedTime::default();
+452        // A backend reports 0.0 for "duration not known yet", not a real zero.
+453        observed.record(5.0, 0.0);
+454        assert_eq!(observed.duration_or_last(None), None);
+455        assert_eq!(observed.position_or_last(None), 5.0);
+456
+457        observed.record(6.0, 180.0);
+458        assert_eq!(observed.duration_or_last(Some(0.0)), Some(180.0));
+459    }
+460
+461    #[test]
+462    fn test_reset_stops_the_previous_file_leaking_into_the_next() {
+463        let mut observed = ObservedTime::default();
+464        observed.record(178.0, 180.0);
+465        observed.reset();
+466
+467        assert_eq!(observed.position_or_last(None), 0.0);
+468        assert_eq!(observed.duration_or_last(None), None);
+469    }
+470
+471    #[test]
+472    fn test_seek_updates_the_last_position_before_the_next_poll() {
+473        let mut observed = ObservedTime::default();
+474        observed.record(10.0, 180.0);
+475        observed.record_position(120.0);
+476
+477        assert_eq!(observed.position_or_last(None), 120.0);
+478        assert_eq!(
+479            observed.duration_or_last(None),
+480            Some(180.0),
+481            "seeking does not change how long the file is"
+482        );
+483    }
+484
+485    #[test]
+486    fn test_resume_tracker_bounds_stalled_retries() {
+487        let mut tracker = ResumeTracker::default();
+488        // Same position over and over: the stream is not recovering.
+489        for n in 1..=MAX_STALLED_RESUME_ATTEMPTS {
+490            assert_eq!(
+491                tracker.allow_attempt(600.0),
+492                Some(n),
+493                "attempts are numbered so callers can back off"
+494            );
+495        }
+496        assert_eq!(
+497            tracker.allow_attempt(600.0),
+498            None,
+499            "a stream that ends at the same position every time must stop retrying"
+500        );
+501    }
+502
+503    #[test]
+504    fn test_resume_tracker_refills_after_progress() {
+505        let mut tracker = ResumeTracker::default();
+506        for _ in 0..MAX_STALLED_RESUME_ATTEMPTS {
+507            tracker.allow_attempt(600.0);
+508        }
+509        assert_eq!(tracker.allow_attempt(600.0), None);
+510        // The next drop happened further in — the resumes are working, so the
+511        // budget must not be exhausted by earlier trouble.
+512        assert_eq!(tracker.allow_attempt(900.0), Some(1));
+513    }
+514
+515    #[test]
+516    fn test_resume_tracker_reset() {
+517        let mut tracker = ResumeTracker::default();
+518        for _ in 0..=MAX_STALLED_RESUME_ATTEMPTS {
+519            tracker.allow_attempt(600.0);
+520        }
+521        tracker.reset();
+522        assert_eq!(tracker.allow_attempt(600.0), Some(1));
+523    }
+524}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/video_surface.rs.html b/api/src/jellytau_lib/player/video_surface.rs.html new file mode 100644 index 00000000..68781ae9 --- /dev/null +++ b/api/src/jellytau_lib/player/video_surface.rs.html @@ -0,0 +1,413 @@ +video_surface.rs - sourceSkip to main content

jellytau_lib/player/
video_surface.rs

1//! The native video surface: mpv drawn *behind* Tauri's webview, without
+2//! touching the widget tree.
+3//!
+4//! # Why there is no overlay here
+5//!
+6//! The obvious arrangement — wrap the webview in a `GtkOverlay` with a
+7//! `GtkGLArea` beneath — attaches cleanly and then aborts the process on the
+8//! first click. `tauri-runtime-wry` connects a button-press handler to the
+9//! webview that walks a hard-coded path:
+10//!
+11//! ```text
+12//! webview.parent()          // "This one should be GtkBox"
+13//!        .parent()          // ...and this one the GtkWindow
+14//!        .downcast::<gtk::Window>().unwrap()
+15//! ```
+16//!
+17//! An overlay makes that chain `webview → GtkOverlay → GtkBox`, the downcast
+18//! fails, and because the panic is non-unwinding it takes the app with it.
+19//! Nothing in configuration avoids it: on Linux the handler is attached
+20//! *unconditionally* (the Windows path guards it behind `is_decorated()`), and
+21//! the decoration check that would make it inert runs *after* the unwrap.
+22//!
+23//! So the widget tree is left exactly as Tauri built it. GTK draws a container
+24//! before its children, so rendering into the vbox's own `draw` handler puts the
+25//! picture underneath the webview for free — the same z-order, no reparenting,
+26//! one less widget, and nothing a Tauri upgrade can invalidate by assuming its
+27//! own layout.
+28//!
+29//! TRACES: UR-080 | DR-231, DR-232, DR-233, IR-033
+30
+31use std::cell::RefCell;
+32use std::ffi::c_void;
+33use std::rc::Rc;
+34use std::sync::atomic::{AtomicBool, Ordering};
+35use std::sync::Arc;
+36
+37use gtk::prelude::*;
+38use gtk::{gdk, glib};
+39use log::{error, info, warn};
+40
+41use super::mpv_render::MpvRenderContext;
+42
+43/// GL enum for `gdk_cairo_draw_from_gl`'s `source_type`. GDK takes the GL
+44/// constant itself rather than an enum of its own.
+45const GL_TEXTURE: i32 = 0x1702;
+46
+47/// Everything the draw handler needs, shared with the GTK callbacks.
+48struct SurfaceState {
+49    gl: Option<gdk::GLContext>,
+50    render: Option<MpvRenderContext>,
+51    mpv: *mut libmpv_sys::mpv_handle,
+52    /// Set by mpv's update callback (on an mpv thread), cleared by the frame
+53    /// clock (on the main thread). The whole cross-thread contract.
+54    frame_ready: Arc<AtomicBool>,
+55    /// The boxed clone of `frame_ready` handed to mpv, reclaimed on teardown.
+56    /// Null when no callback is registered.
+57    callback_ctx: *mut Arc<AtomicBool>,
+58    // One-shot diagnostic latches; see `draw`.
+59    logged_first_draw: bool,
+60    logged_first_frame: bool,
+61    /// Last size we logged, so a size change re-reports rather than staying silent.
+62    logged_size: (i32, i32),
+63    logged_no_gl: bool,
+64    logged_no_window: bool,
+65    logged_no_size: bool,
+66    logged_render_fail: bool,
+67}
+68
+69impl SurfaceState {
+70    /// Tear down in the order DR-232 requires, with the GL context current.
+71    ///
+72    /// The update callback is unregistered before the context is freed (inside
+73    /// `destroy`), and the GL objects go while their context is still bound.
+74    /// Getting this wrong is DR-184 on Android restated — a surface outliving
+75    /// its player — and is the likeliest cause of the one unexplained SIGSEGV
+76    /// the spike recorded.
+77    fn teardown(&mut self) {
+78        if let Some(render) = self.render.take() {
+79            if let Some(gl) = &self.gl {
+80                gl.make_current();
+81            }
+82            // Unregisters the callback before freeing the context.
+83            unsafe { render.destroy() };
+84        }
+85        // Only now is it safe to reclaim what the callback was holding: mpv can
+86        // no longer reach it. Freeing it first would be the use-after-free this
+87        // ordering exists to prevent.
+88        if !self.callback_ctx.is_null() {
+89            unsafe { drop(Box::from_raw(self.callback_ctx)) };
+90            self.callback_ctx = std::ptr::null_mut();
+91        }
+92        self.gl = None;
+93    }
+94}
+95
+96/// A live video surface. Dropping it tears the render context down.
+97pub struct VideoSurface {
+98    state: Rc<RefCell<SurfaceState>>,
+99    widget: gtk::Box,
+100    handlers: Vec<glib::SignalHandlerId>,
+101}
+102
+103impl Drop for VideoSurface {
+104    fn drop(&mut self) {
+105        for id in self.handlers.drain(..) {
+106            self.widget.disconnect(id);
+107        }
+108        self.state.borrow_mut().teardown();
+109        self.widget.queue_draw();
+110        info!("[VideoSurface] detached");
+111    }
+112}
+113
+114/// mpv's update callback. Runs on an mpv thread, so it does the least possible:
+115/// flags the state and asks GTK to redraw on the main loop.
+116///
+117/// **Nothing here may block or re-enter the player.** The project's deadlock
+118/// gotcha applies with full force — this is called from mpv's own threads.
+119///
+120/// TRACES: UR-080 | DR-233
+121unsafe extern "C" fn on_mpv_update(ctx: *mut c_void) {
+122    if ctx.is_null() {
+123        return;
+124    }
+125    // Runs on an *mpv* thread. It therefore does exactly one thing that is safe
+126    // to do from there: set an atomic flag.
+127    //
+128    // It must not touch GTK, and specifically must not schedule work with
+129    // `idle_add_local*`, which requires the calling thread to own the default
+130    // main context — from here that panics with "default main context already
+131    // acquired by another thread". Nor can it hold the `Rc<RefCell<..>>` state:
+132    // an `Rc` is not `Send`, and cloning one from two threads races its
+133    // refcount.
+134    //
+135    // The frame clock on the widget picks the flag up on the main thread. See
+136    // `install_frame_clock`.
+137    let flag = &*(ctx as *const Arc<AtomicBool>);
+138    flag.store(true, Ordering::Release);
+139}
+140
+141/// Start drawing mpv's video underneath the webview.
+142///
+143/// `vbox` is Tauri's `default_vbox()` — the container the webview already lives
+144/// in. It is not modified; only a `draw` handler is added.
+145///
+146/// Must run on the GTK main thread.
+147///
+148/// TRACES: UR-080 | DR-231, DR-232, DR-233
+149pub fn attach(vbox: &gtk::Box, mpv: *mut libmpv_sys::mpv_handle) -> bool {
+150    if mpv.is_null() {
+151        warn!("[VideoSurface] no mpv handle; native video unavailable");
+152        return false;
+153    }
+154
+155    let state = Rc::new(RefCell::new(SurfaceState {
+156        gl: None,
+157        render: None,
+158        mpv,
+159        frame_ready: Arc::new(AtomicBool::new(false)),
+160        callback_ctx: std::ptr::null_mut(),
+161        logged_first_draw: false,
+162        logged_first_frame: false,
+163        logged_size: (0, 0),
+164        logged_no_gl: false,
+165        logged_no_window: false,
+166        logged_no_size: false,
+167        logged_render_fail: false,
+168    }));
+169
+170    let mut handlers = Vec::new();
+171
+172    // The GL context can only be created once the widget has a GdkWindow, which
+173    // is what `realize` announces. Creating it earlier leaves nothing to attach
+174    // to — the same ordering constraint the render context has.
+175    let realize_state = state.clone();
+176    handlers.push(vbox.connect_realize(move |widget| {
+177        if let Err(e) = init_gl(widget, &realize_state) {
+178            error!("[VideoSurface] GL init failed: {e}");
+179        }
+180    }));
+181
+182    // A render context outliving its GL context is the defect DR-232 exists to
+183    // prevent, so teardown is bound to `unrealize` rather than left to Drop.
+184    let unrealize_state = state.clone();
+185    handlers.push(vbox.connect_unrealize(move |_| {
+186        unrealize_state.borrow_mut().teardown();
+187    }));
+188
+189    // Drive the render loop from the widget's frame clock, on the main thread,
+190    // rendering only when mpv actually has a frame.
+191    //
+192    // Both nearby mistakes were made and are worth naming, because each has a
+193    // symptom that points somewhere else:
+194    //
+195    //   - Waiting on mpv's update callback before rendering deadlocks. mpv does
+196    //     not progress until the client renders. The file loads, one frame
+197    //     appears, and everything stops — no picture, no audio, a spinner that
+198    //     never clears. It reads as a broken stream.
+199    //   - Rendering unconditionally every tick and reporting a swap each time
+200    //     tells mpv a frame reached the screen far more often than one did. It
+201    //     plays, and judders badly. It reads as a GPU or compositing limit.
+202    //
+203    // Polling `has_frame` each tick is neither.
+204    //
+205    // The frame clock only ticks while the widget is mapped, so this costs
+206    // nothing when the window is hidden.
+207    //
+208    // TRACES: UR-080 | DR-233
+209    let tick_state = state.clone();
+210    vbox.add_tick_callback(move |widget, _clock| {
+211        // Ask mpv, on the main thread, whether there is anything new. The
+212        // update callback's flag is only a hint that something *may* have
+213        // happened; `has_frame` is the authority, and asking it here is what
+214        // keeps this from either deadlocking or over-presenting.
+215        let ready = match tick_state.try_borrow() {
+216            Ok(s) => {
+217                s.frame_ready.swap(false, Ordering::AcqRel);
+218                match s.render.as_ref() {
+219                    Some(render) => unsafe { render.has_frame() },
+220                    None => false,
+221                }
+222            }
+223            Err(_) => false,
+224        };
+225        if ready {
+226            widget.queue_draw();
+227        }
+228        glib::ControlFlow::Continue
+229    });
+230
+231    let draw_state = state.clone();
+232    handlers.push(vbox.connect_draw(move |widget, cr| {
+233        draw(widget, cr, &draw_state);
+234        // Propagate: the webview is a child and must still draw over us.
+235        glib::Propagation::Proceed
+236    }));
+237
+238    // The window is already up by the time we are called, so run the init the
+239    // `realize` signal would have.
+240    if vbox.is_realized() {
+241        if let Err(e) = init_gl(vbox, &state) {
+242            error!("[VideoSurface] GL init failed: {e}");
+243        }
+244    }
+245
+246    info!("[VideoSurface] attached to Tauri's vbox without reparenting");
+247    // The surface lives as long as the window. Held in a thread-local rather
+248    // than returned, because it owns `Rc` and GTK types and so is neither `Send`
+249    // nor `Sync` — it cannot go into Tauri's managed state, and leaking it would
+250    // give up the ability to tear it down at all.
+251    //
+252    // Teardown does not depend on this being dropped: it is driven by the
+253    // widget's `unrealize`, which is the signal that actually means "your GL
+254    // context is going away" (DR-232).
+255    LIVE_SURFACE.with(|cell| {
+256        *cell.borrow_mut() = Some(VideoSurface {
+257            state,
+258            widget: vbox.clone(),
+259            handlers,
+260        });
+261    });
+262    true
+263}
+264
+265thread_local! {
+266    /// The one live surface, on the GTK main thread.
+267    static LIVE_SURFACE: RefCell<Option<VideoSurface>> = const { RefCell::new(None) };
+268}
+269
+270/// Drop the live surface, if there is one. Idempotent.
+271///
+272/// TRACES: UR-080 | DR-232
+273#[allow(dead_code)]
+274pub fn detach() {
+275    LIVE_SURFACE.with(|cell| {
+276        cell.borrow_mut().take();
+277    });
+278}
+279
+280/// Create the GL context and the mpv render context over it.
+281fn init_gl(widget: &gtk::Box, state: &Rc<RefCell<SurfaceState>>) -> Result<(), String> {
+282    if state.borrow().render.is_some() {
+283        return Ok(());
+284    }
+285    let window = widget.window().ok_or("widget has no GdkWindow")?;
+286
+287    let gl = window
+288        .create_gl_context()
+289        .map_err(|e| format!("create_gl_context: {e}"))?;
+290    gl.realize().map_err(|e| format!("realize: {e}"))?;
+291    gl.make_current();
+292
+293    let mpv = state.borrow().mpv;
+294    let mut render =
+295        unsafe { MpvRenderContext::new(mpv) }.ok_or("mpv render context creation failed")?;
+296
+297    // The callback needs an owned handle that outlives this function, so a
+298    // clone of the flag is boxed and leaked. `Arc<AtomicBool>` rather than the
+299    // state itself: it is the only thing that may cross to an mpv thread. The
+300    // pointer is kept so teardown can reclaim it — after the callback is
+301    // unregistered, never before.
+302    let flag = state.borrow().frame_ready.clone();
+303    let ctx_box: *mut Arc<AtomicBool> = Box::into_raw(Box::new(flag));
+304    unsafe { render.set_update_callback(Some(on_mpv_update), ctx_box as *mut c_void) };
+305
+306    let mut s = state.borrow_mut();
+307    s.gl = Some(gl);
+308    s.render = Some(render);
+309    s.callback_ctx = ctx_box;
+310    info!("[VideoSurface] GL and render context ready");
+311    Ok(())
+312}
+313
+314/// Draw the current frame, if there is one.
+315///
+316/// Runs *before* the children, which is what puts the picture behind the
+317/// webview. Deliberately forgiving: no frame, no GL, or a borrowed state all
+318/// mean "draw nothing this pass" rather than an error — the webview then paints
+319/// over an untouched background, which is exactly the pre-native appearance.
+320fn draw(widget: &gtk::Box, cr: &gtk::cairo::Context, state: &Rc<RefCell<SurfaceState>>) {
+321    // Report each way of doing nothing exactly once. Without this the whole
+322    // path is invisible: a draw handler that never runs, one that bails on a
+323    // zero allocation, and one that renders perfectly all look identical from
+324    // outside — and mpv stalls if frames are never consumed, so "no audio and
+325    // it hangs" is a plausible symptom of *any* of them.
+326    fn once(flag: &mut bool, msg: &str) {
+327        if !*flag {
+328            *flag = true;
+329            warn!("[VideoSurface] not drawing: {msg}");
+330        }
+331    }
+332
+333    let Ok(mut s) = state.try_borrow_mut() else {
+334        return;
+335    };
+336    if !s.logged_first_draw {
+337        s.logged_first_draw = true;
+338        info!("[VideoSurface] draw handler running");
+339    }
+340    let Some(gl) = s.gl.clone() else {
+341        let f = &mut s.logged_no_gl;
+342        once(f, "no GL context");
+343        return;
+344    };
+345    let Some(window) = widget.window() else {
+346        let f = &mut s.logged_no_window;
+347        once(f, "widget has no GdkWindow");
+348        return;
+349    };
+350
+351    let scale = widget.scale_factor();
+352    let width = widget.allocated_width() * scale;
+353    let height = widget.allocated_height() * scale;
+354    if width <= 0 || height <= 0 {
+355        let f = &mut s.logged_no_size;
+356        once(f, "zero allocation");
+357        return;
+358    }
+359
+360    gl.make_current();
+361
+362    // Render and end the mutable borrow before touching the latches again.
+363    let rendered = match s.render.as_mut() {
+364        Some(render) => unsafe { render.render(width, height) },
+365        None => return,
+366    };
+367    let Some(texture) = rendered else {
+368        let f = &mut s.logged_render_fail;
+369        once(f, "mpv render produced no texture");
+370        return;
+371    };
+372    // Log the first frame, and again whenever the target size changes. Latching
+373    // this once per session hid the case that matters: a second file, rendered
+374    // at a different size, in a window that never moved. "The picture is a small
+375    // box in the middle" and "the picture fills the widget" are indistinguishable
+376    // from outside without it.
+377    if !s.logged_first_frame || s.logged_size != (width, height) {
+378        s.logged_first_frame = true;
+379        s.logged_size = (width, height);
+380        // The allocation *origin* matters as much as its size. A GtkBox is a
+381        // no-window widget, so `widget.window()` is the parent's GdkWindow and
+382        // the box sits at an offset inside it. `draw_from_gl` composites into
+383        // that window; if it does not honour the cairo translation GTK applied
+384        // for this widget, the picture lands at the window origin instead of
+385        // the widget's — misaligned by exactly this offset, which is the shape
+386        // of a letterbox that does not line up.
+387        let alloc = widget.allocation();
+388        info!(
+389            "[VideoSurface] rendering {width}x{height} at widget origin ({}, {}) scale {scale} (texture {texture})",
+390            alloc.x(),
+391            alloc.y()
+392        );
+393    }
+394
+395    unsafe {
+396        cr.draw_from_gl(
+397            &window,
+398            texture as i32,
+399            GL_TEXTURE,
+400            scale,
+401            0,
+402            0,
+403            width,
+404            height,
+405        );
+406        // Tell mpv the frame reached the screen. Without this it has nothing to
+407        // pace against — see DR-233.
+408        if let Some(render) = s.render.as_ref() {
+409            render.report_swap();
+410        }
+411    }
+412}
+
\ 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..6ea453cb --- /dev/null +++ b/api/src/jellytau_lib/repository/device_profile.rs.html @@ -0,0 +1,640 @@ +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    // Where the video renderer decodes the audio itself (ExoPlayer), the
+216    // platform list *is* the answer and narrowing it to the webview's throws
+217    // away codecs the device genuinely plays — dts, on the tablet this was
+218    // found on. TRACES: UR-004, UR-080 | DR-234
+219    #[cfg(target_os = "android")]
+220    {
+221        return detected.to_string();
+222    }
+223
+224    #[allow(unreachable_code)]
+225    let kept: Vec<&str> = detected
+226        .split(',')
+227        .filter_map(|codec| {
+228            let codec = codec.trim();
+229            // Match case-insensitively but emit our own spelling: the platform
+230            // list is assembled from MIME strings and its casing is not ours to
+231            // forward to the server.
+232            WEBVIEW_AUDIO_CODECS
+233                .iter()
+234                .copied()
+235                .find(|supported| supported.eq_ignore_ascii_case(codec))
+236        })
+237        .collect();
+238
+239    if kept.is_empty() {
+240        FALLBACK_AUDIO_CODEC.to_string()
+241    } else {
+242        kept.join(",")
+243    }
+244}
+245
+246/// What the renderer that will actually decode video on this platform can play.
+247///
+248/// Returns `(video_codecs, audio_codecs)` as Jellyfin-style comma lists.
+249///
+250/// This exists because the answer was previously derived in four places and
+251/// hardcoded in a fifth, each of them assuming the *webview* was decoding:
+252/// the device profile, the transcoding targets, the direct-play audio
+253/// narrowing, the client-side audio override, and `get_video_stream_url`'s
+254/// `VideoCodec`. On Android the decoder is ExoPlayer, so every one of those was
+255/// wrong there — the observed cost being an hevc source re-encoded to h264
+256/// because its *audio* was eac3, and dts forced to transcode though the device
+257/// decodes it.
+258///
+259/// One source, so the copies cannot disagree again.
+260///
+261/// TRACES: UR-004, UR-080 | DR-234
+262pub fn renderer_codecs() -> (String, String) {
+263    #[cfg(target_os = "android")]
+264    {
+265        // ExoPlayer, and the device itself answers via MediaCodecList.
+266        crate::player::get_detected_codecs()
+267            .map(|(video, audio, _channels)| (video, audio))
+268            .unwrap_or_else(|| {
+269                log::warn!(
+270                    "[DeviceProfile] Codec detection not complete, using conservative defaults"
+271                );
+272                ("h264,hevc".to_string(), "aac,mp3".to_string())
+273            })
+274    }
+275
+276    // Linux desktop draws video in the WebKitGTK HTML5 <video> element, which
+277    // cannot reliably decode HEVC/AV1/VP9. Claim only what it decodes, so
+278    // Jellyfin transcodes the rest to h264 HLS. (Audio-only playback goes
+279    // through MPV and is unaffected — that is a different renderer and a
+280    // different profile.)
+281    //
+282    // When mpv draws the picture here this stops being a platform constant and
+283    // becomes a question about the active renderer — which is the whole point of
+284    // returning it from a function rather than a `cfg` block.
+285    #[cfg(all(not(target_os = "android"), target_os = "linux"))]
+286    {
+287        ("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string())
+288    }
+289
+290    #[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
+291    {
+292        (
+293            "h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
+294            "aac,mp3,opus,vorbis,flac".to_string(),
+295        )
+296    }
+297}
+298
+299/// Whether the renderer that decodes *video* on this platform can also decode
+300/// this audio codec.
+301///
+302/// On a webview platform this is the webview's narrow list, because the element
+303/// decodes both halves. On Android it is the device's own list: ExoPlayer plays
+304/// the audio, so judging it against the webview's capabilities transcodes files
+305/// that would have played.
+306///
+307/// TRACES: UR-004, UR-080 | DR-234
+308pub fn renderer_can_decode_audio(codec: &str) -> bool {
+309    let codec = codec.trim();
+310    #[cfg(target_os = "android")]
+311    {
+312        let (_video, audio) = renderer_codecs();
+313        return audio
+314            .split(',')
+315            .any(|supported| supported.trim().eq_ignore_ascii_case(codec));
+316    }
+317    #[cfg(not(target_os = "android"))]
+318    {
+319        webview_can_decode_audio(codec)
+320    }
+321}
+322
+323/// Whether the webview `<video>` element can decode this audio codec.
+324///
+325/// TRACES: UR-004 | DR-149 | UT-148
+326pub fn webview_can_decode_audio(codec: &str) -> bool {
+327    WEBVIEW_AUDIO_CODECS
+328        .iter()
+329        .any(|supported| supported.eq_ignore_ascii_case(codec.trim()))
+330}
+331
+332/// Decide whether we must transcode *regardless of what the server negotiated*,
+333/// given the source's audio streams as `(codec, is_default)` in source order.
+334///
+335/// Advertising a narrow profile ([`video_audio_codecs`]) is necessary but not
+336/// sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s container and
+337/// video codec but **ignores its audio codec** — an E-AC-3 track is offered for
+338/// direct play even when the profile lists only AAC, and neither a `VideoAudio`
+339/// `CodecProfile` nor `MaxAudioChannels` changes that. So the client cannot
+340/// delegate this decision; it knows what its own renderer can decode and must
+341/// apply that itself.
+342///
+343/// The track that matters is the one the server will actually serve (see
+344/// [`served_audio_codec`]). An unknown codec is left alone — forcing a transcode
+345/// on a guess would burn server CPU for files that play.
+346///
+347/// TRACES: UR-004 | DR-149 | UT-148
+348pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
+349    match served_audio_codec(streams) {
+350        // The renderer that will decode it, not always the webview — see
+351        // `renderer_can_decode_audio`. TRACES: UR-004, UR-080 | DR-234
+352        Some(codec) => !renderer_can_decode_audio(codec),
+353        // No audio at all, or a codec the server did not name: leave it alone.
+354        None => false,
+355    }
+356}
+357
+358/// The codec of the audio track the server will actually serve, given the
+359/// source's audio streams as `(codec, is_default)` in source order: the default,
+360/// or the first when none is marked.
+361///
+362/// `None` means "nothing to judge" — no audio streams, or the server named no
+363/// codec for the one it would serve. Both callers of this rule treat that as
+364/// leave-well-alone, never as a licence to assume compatibility.
+365///
+366/// TRACES: UR-004, UR-071 | DR-149, DR-171 | UT-148, UT-166
+367pub fn served_audio_codec<'a>(streams: &[(Option<&'a str>, bool)]) -> Option<&'a str> {
+368    streams
+369        .iter()
+370        .find(|(_, is_default)| *is_default)
+371        .or_else(|| streams.first())
+372        .and_then(|(codec, _)| *codec)
+373}
+374
+375#[cfg(test)]
+376mod tests {
+377    use super::*;
+378
+379    /// The reported bug, at the level it was decided: a source whose default
+380    /// subtitle track is PGSSUB must not drag the video into a re-encode.
+381    ///
+382    /// TRACES: UR-020 | DR-176 | UT-168
+383    #[test]
+384    fn no_subtitle_stream_is_negotiated_so_the_server_never_burns_one_in() {
+385        assert_eq!(playback_subtitle_stream_index(), NO_SUBTITLE_STREAM);
+386        // Not `None`/omitted: that is what let the server pick the PGS track.
+387        assert_eq!(playback_subtitle_stream_index(), -1);
+388    }
+389
+390    /// A transcode URL the *server* built carries the server's own subtitle
+391    /// verdict. Adopting it verbatim re-introduces the burn-in
+392    /// [`playback_subtitle_stream_index`] exists to prevent — the negotiation
+393    /// asks for no subtitle, and then we play a URL that asks for one anyway.
+394    ///
+395    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
+396    #[test]
+397    fn a_server_built_transcode_url_has_its_burn_in_stripped() {
+398        // Shape taken from Jellyfin's `StreamInfo.ToUrl`: it appends
+399        // `SubtitleStreamIndex` and `SubtitleMethod` whenever it picked a track.
+400        let served = "/videos/abc/master.m3u8?DeviceId=jt&MediaSourceId=src1\
+401             &VideoCodec=h264&SubtitleMethod=Encode&SubtitleStreamIndex=2\
+402             &PlaySessionId=xyz";
+403
+404        let url = without_server_chosen_subtitle(served);
+405
+406        assert!(
+407            url.contains("SubtitleStreamIndex=-1"),
+408            "the adopted URL must ask for no subtitle: {url}"
+409        );
+410        assert!(
+411            !url.contains("SubtitleStreamIndex=2"),
+412            "the server's chosen track must not survive: {url}"
+413        );
+414        assert!(
+415            !url.contains("SubtitleMethod"),
+416            "burn-in must not be requested: {url}"
+417        );
+418        // Everything else identifies the job and must survive untouched.
+419        for kept in [
+420            "DeviceId=jt",
+421            "MediaSourceId=src1",
+422            "VideoCodec=h264",
+423            "PlaySessionId=xyz",
+424        ] {
+425            assert!(url.contains(kept), "{kept} must survive: {url}");
+426        }
+427    }
+428
+429    /// The server may also be told to burn in unconditionally
+430    /// (`alwaysBurnInSubtitleWhenTranscoding`), which is appended to the URL
+431    /// rather than expressed as a method — and its keys are not PascalCase.
+432    ///
+433    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
+434    #[test]
+435    fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() {
+436        let url = without_server_chosen_subtitle(
+437            "/videos/abc/master.m3u8?api_key=k&alwaysBurnInSubtitleWhenTranscoding=true\
+438             &subtitlestreamindex=3&SubtitleCodec=ass",
+439        );
+440
+441        assert!(!url.to_lowercase().contains("alwaysburnin"), "{url}");
+442        assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}");
+443        assert!(!url.contains("subtitlestreamindex=3"), "{url}");
+444        assert!(url.contains("SubtitleStreamIndex=-1"), "{url}");
+445        assert!(url.contains("api_key=k"), "{url}");
+446    }
+447
+448    /// A URL the server built without any subtitle in it still has to *say* so:
+449    /// omitting the index is what makes the server apply the source's default.
+450    ///
+451    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
+452    #[test]
+453    fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() {
+454        let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?api_key=k");
+455        assert_eq!(
+456            url,
+457            "/videos/abc/master.m3u8?api_key=k&SubtitleStreamIndex=-1"
+458        );
+459
+460        // A bare URL is rare but must not come out malformed.
+461        let bare = without_server_chosen_subtitle("/videos/abc/master.m3u8");
+462        assert_eq!(bare, "/videos/abc/master.m3u8?SubtitleStreamIndex=-1");
+463    }
+464
+465    /// TRACES: UR-020 | DR-176 | UT-168
+466    #[test]
+467    fn text_subtitles_are_advertised_as_external_sidecars() {
+468        let profiles = subtitle_profiles();
+469        for format in ["srt", "subrip", "ass", "ssa", "vtt"] {
+470            let entry = profiles.iter().find(|(f, _)| *f == format);
+471            assert!(
+472                entry.is_some(),
+473                "{format} must be advertised or the server burns it into the picture"
+474            );
+475            assert_eq!(entry.unwrap().1, "External");
+476        }
+477    }
+478
+479    /// TRACES: UR-020 | DR-176 | UT-168
+480    #[test]
+481    fn text_subtitles_never_force_burn_in_but_image_ones_do() {
+482        // Text: deliverable as a sidecar, so the video can still be remuxed.
+483        assert!(!subtitle_forces_burn_in("subrip"));
+484        assert!(!subtitle_forces_burn_in("ASS"));
+485        assert!(!subtitle_forces_burn_in("ssa"));
+486        // Image formats are bitmaps — the server can only composite them.
+487        assert!(subtitle_forces_burn_in("PGSSUB"));
+488        assert!(subtitle_forces_burn_in("dvdsub"));
+489    }
+490
+491    #[test]
+492    fn an_undecodable_default_track_forces_a_transcode() {
+493        // The reported bug: one E-AC-3 track, which the webview cannot decode.
+494        assert!(audio_forces_transcode(&[(Some("eac3"), false)]));
+495        assert!(audio_forces_transcode(&[(Some("ac3"), true)]));
+496    }
+497
+498    #[test]
+499    fn a_decodable_track_is_left_to_direct_play() {
+500        // Never spend server CPU on a file that already plays.
+501        assert!(!audio_forces_transcode(&[(Some("aac"), true)]));
+502        assert!(!audio_forces_transcode(&[(Some("mp3"), false)]));
+503    }
+504
+505    #[test]
+506    fn the_default_track_decides_not_the_first() {
+507        // The webview plays the default track, so that is the one that has to be
+508        // decodable — a supported track further down does not save us.
+509        assert!(audio_forces_transcode(&[
+510            (Some("aac"), false),
+511            (Some("eac3"), true)
+512        ]));
+513        assert!(!audio_forces_transcode(&[
+514            (Some("eac3"), false),
+515            (Some("aac"), true)
+516        ]));
+517    }
+518
+519    #[test]
+520    fn with_no_default_marked_the_first_track_decides() {
+521        // Jellyfin leaves IsDefault false on every stream for some files; the
+522        // server then serves the first, so judge that one.
+523        assert!(audio_forces_transcode(&[
+524            (Some("eac3"), false),
+525            (Some("aac"), false)
+526        ]));
+527    }
+528
+529    #[test]
+530    fn a_source_with_no_audio_is_not_transcoded() {
+531        // Nothing to rescue, and a transcode would not create audio.
+532        assert!(!audio_forces_transcode(&[]));
+533    }
+534
+535    #[test]
+536    fn an_unknown_codec_is_not_second_guessed() {
+537        // The server did not tell us the codec; assuming the worst would
+538        // transcode files that play perfectly.
+539        assert!(!audio_forces_transcode(&[(None, true)]));
+540    }
+541
+542    /// The download path needs the codec itself, not just the verdict, so it can
+543    /// tell the server what to re-encode. It picks the same track the streaming
+544    /// verdict is formed from — one rule, one place.
+545    ///
+546    /// TRACES: UR-071 | DR-171 | UT-166
+547    #[test]
+548    fn the_served_codec_is_the_one_the_verdict_is_formed_from() {
+549        assert_eq!(
+550            served_audio_codec(&[(Some("aac"), false), (Some("eac3"), true)]),
+551            Some("eac3")
+552        );
+553        assert_eq!(
+554            served_audio_codec(&[(Some("eac3"), false), (Some("aac"), false)]),
+555            Some("eac3")
+556        );
+557        assert_eq!(served_audio_codec(&[]), None);
+558        assert_eq!(served_audio_codec(&[(None, true)]), None);
+559    }
+560
+561    #[test]
+562    fn a_dolby_device_does_not_advertise_dolby_for_video() {
+563        // The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
+564        // E-AC-3 into a webview that cannot decode it — silent video, on that
+565        // device only.
+566        let codecs = video_audio_codecs("aac,ac3,amrnb,amrwb,eac3,flac,mp3,opus,pcm,vorbis");
+567        assert_eq!(codecs, "aac,flac,mp3,opus,vorbis");
+568    }
+569
+570    #[test]
+571    fn codecs_the_webview_cannot_decode_are_dropped() {
+572        // AMR and raw PCM come from the AOSP set, so this is not a Dolby-only
+573        // problem — it is just rarer content.
+574        assert_eq!(video_audio_codecs("amrnb,amrwb,pcm,aac"), "aac");
+575        assert_eq!(video_audio_codecs("dts,truehd,mp3"), "mp3");
+576    }
+577
+578    #[test]
+579    fn a_list_the_webview_fully_supports_is_untouched() {
+580        assert_eq!(
+581            video_audio_codecs("aac,mp3,opus,vorbis,flac"),
+582            "aac,mp3,opus,vorbis,flac"
+583        );
+584    }
+585
+586    #[test]
+587    fn nothing_decodable_still_claims_aac() {
+588        // Claiming an empty list invites the server to give up rather than
+589        // transcode. AAC is universally decodable, so ask for it.
+590        assert_eq!(video_audio_codecs("eac3,dts"), "aac");
+591        assert_eq!(video_audio_codecs(""), "aac");
+592    }
+593
+594    #[test]
+595    fn spacing_and_case_in_the_platform_list_are_tolerated() {
+596        // The list is assembled from MediaCodecList strings; do not let
+597        // whitespace decide whether the user gets sound.
+598        assert_eq!(video_audio_codecs("aac, EAC3 , Mp3"), "aac,mp3");
+599    }
+600
+601    #[test]
+602    fn an_unknown_route_falls_back_to_stereo() {
+603        // Codec detection has not run yet, or the platform has no answer. Never
+604        // claim surround we have not seen — every sink can do stereo.
+605        assert_eq!(clamp_max_audio_channels(None), 2);
+606    }
+607
+608    #[test]
+609    fn a_zero_reading_is_not_a_capability() {
+610        // A route that has not been established reports 0; taking that literally
+611        // would advertise a device with no audio at all.
+612        assert_eq!(clamp_max_audio_channels(Some(0)), 2);
+613    }
+614
+615    #[test]
+616    fn a_stereo_sink_is_reported_as_stereo() {
+617        // The phone speaker / Bluetooth headset case: the server must downmix
+618        // 5.1 rather than direct-play it.
+619        assert_eq!(clamp_max_audio_channels(Some(2)), 2);
+620    }
+621
+622    #[test]
+623    fn a_surround_route_keeps_its_channels() {
+624        // HDMI to an AVR: 5.1 and 7.1 direct play stay available.
+625        assert_eq!(clamp_max_audio_channels(Some(6)), 6);
+626        assert_eq!(clamp_max_audio_channels(Some(8)), 8);
+627    }
+628
+629    #[test]
+630    fn an_absurd_reading_is_capped_rather_than_forwarded() {
+631        // Some drivers report the AudioTrack maximum rather than the route's.
+632        assert_eq!(clamp_max_audio_channels(Some(32)), 8);
+633    }
+634
+635    #[test]
+636    fn a_mono_route_is_taken_at_its_word() {
+637        assert_eq!(clamp_max_audio_channels(Some(1)), 1);
+638    }
+639}
+
\ 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..ba27a86e --- /dev/null +++ b/api/src/jellytau_lib/repository/hybrid.rs.html @@ -0,0 +1,2201 @@ +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    /// Decide what stream to play and describe it fully — the DR-225 contract.
+101    ///
+102    /// Online-only for the same reason as `get_video_stream_url`: an offline
+103    /// item is a file on disk, and the caller builds
+104    /// [`StreamSelection::local_file`] for it rather than negotiating anything.
+105    ///
+106    /// TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228
+107    pub async fn get_stream_selection(
+108        &self,
+109        item_id: &str,
+110        media_source_id: Option<&str>,
+111        audio_stream_index: Option<i32>,
+112    ) -> Result<super::StreamSelection, RepoError> {
+113        self.online
+114            .get_stream_selection(item_id, media_source_id, audio_stream_index)
+115            .await
+116    }
+117
+118    /// Get an audio-only stream URL for a video item (background-audio handoff).
+119    /// Online-only, like `get_video_stream_url`.
+120    ///
+121    /// TRACES: UR-040 | JA-032
+122    pub async fn get_audio_only_stream_url_for_video(
+123        &self,
+124        item_id: &str,
+125        media_source_id: Option<&str>,
+126        start_time_seconds: Option<f64>,
+127        audio_stream_index: Option<i32>,
+128    ) -> Result<String, RepoError> {
+129        self.online
+130            .get_audio_only_stream_url_for_video(
+131                item_id,
+132                media_source_id,
+133                start_time_seconds,
+134                audio_stream_index,
+135            )
+136            .await
+137    }
+138
+139    /// Every track of an album, asked of the **server** rather than the cache.
+140    ///
+141    /// Deliberately not `get_items`, which is cache-first: it answers from SQLite
+142    /// the moment the cache has any content. That is right for browsing and wrong
+143    /// for deciding what to download, because a partial or unlinked cache then
+144    /// decides how much of the album gets queued while the user is told the whole
+145    /// album is downloading. Downloading is the one operation that must know the
+146    /// album's *complete* contents.
+147    ///
+148    /// Errors when the server cannot answer (offline); the caller falls back to
+149    /// the local catalog and the rows are queued either way, resolving on
+150    /// reconnect. Server results are written back to the cache, so browsing
+151    /// benefits from the round trip too.
+152    ///
+153    /// TRACES: UR-018, UR-055 | DR-173
+154    pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<MediaItem>, RepoError> {
+155        let options = Some(GetItemsOptions {
+156            include_item_types: Some(vec!["Audio".to_string()]),
+157            sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
+158            limit: Some(1000),
+159            ..Default::default()
+160        });
+161
+162        let result = self.online.get_items(album_id, options).await?;
+163
+164        if !result.items.is_empty() {
+165            if let Err(e) = self.offline.save_to_cache(album_id, &result.items).await {
+166                warn!("[HybridRepo] Failed to cache album tracks: {:?}", e);
+167            }
+168        }
+169
+170        Ok(result.items)
+171    }
+172
+173    /// Immediate children of a container with the user's browsing exclusions
+174    /// **not** applied.
+175    ///
+176    /// Exists for the exclusion picker in settings. Everything else in this
+177    /// repository hides what the user has hidden, which would make the setting
+178    /// one-way: a folder already excluded would vanish from the list of folders
+179    /// to exclude and could never be un-hidden. Server-first so the picker sees
+180    /// the real library, falling back to the cache when unreachable.
+181    ///
+182    /// TRACES: UR-076 | DR-209
+183    pub async fn get_items_unfiltered(
+184        &self,
+185        parent_id: &str,
+186        options: Option<GetItemsOptions>,
+187    ) -> Result<SearchResult, RepoError> {
+188        match self.online.get_items(parent_id, options.clone()).await {
+189            Ok(result) => Ok(result),
+190            Err(e) => self.offline.get_items(parent_id, options).await.or(Err(e)),
+191        }
+192    }
+193
+194    /// Search only the local SQLite cache (downloaded content).
+195    ///
+196    /// Fast (100ms timeout) — used to render instant results before the server
+197    /// responds. Returns an empty result rather than erroring on timeout so the
+198    /// caller can still fall through to the server.
+199    pub async fn search_cache_only(
+200        &self,
+201        query: &str,
+202        options: Option<SearchOptions>,
+203    ) -> Result<SearchResult, RepoError> {
+204        let offline = Arc::clone(&self.offline);
+205        let query = query.to_string();
+206        self.cache_with_timeout(async move { offline.search(&query, options).await })
+207            .await
+208            .map(ExcludeHidden::without_excluded)
+209    }
+210
+211    /// Favourites held locally, without touching the server. Backs the instant
+212    /// leg of the two-phase favourites read in the command layer.
+213    ///
+214    /// TRACES: UR-067 | DR-115
+215    pub async fn get_favorites_cache_only(
+216        &self,
+217        scope: SearchScope,
+218        options: Option<GetItemsOptions>,
+219    ) -> Result<SearchResult, RepoError> {
+220        let offline = Arc::clone(&self.offline);
+221        self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
+222            .await
+223            .map(ExcludeHidden::without_excluded)
+224    }
+225
+226    /// Favourites straight from the server, persisted to the cache on the way
+227    /// through — which is also what mirrors their favourite flags into
+228    /// `user_data` (DR-114), so the next offline read agrees with the server.
+229    ///
+230    /// TRACES: UR-067 | DR-115
+231    pub async fn get_favorites_server_only(
+232        &self,
+233        scope: SearchScope,
+234        options: Option<GetItemsOptions>,
+235    ) -> Result<SearchResult, RepoError> {
+236        let result = self.online.get_favorites(scope, options).await?;
+237        if !result.items.is_empty() {
+238            // Favourites span libraries, so there is no single parent to file
+239            // them under; the parent id is only used for stub rows.
+240            if let Err(e) = self.offline.save_to_cache("favorites", &result.items).await {
+241                debug!("[HybridRepo] Failed to cache favourites: {:?}", e);
+242            }
+243        }
+244        Ok(result.without_excluded())
+245    }
+246
+247    /// Fetch a folder's items from the live server and persist them to the
+248    /// offline cache synchronously (unlike `get_items`, which saves in a
+249    /// fire-and-forget background task after a 100ms cache race).
+250    ///
+251    /// Used by the full-catalog pre-sync (`sync_full_catalog`) to deterministically
+252    /// walk every library while online so the whole catalog is browsable — greyed
+253    /// out — offline. Returns the items fetched so the caller can recurse into
+254    /// containers. Server-only: errors if unreachable.
+255    pub async fn cache_items_from_server(
+256        &self,
+257        parent_id: &str,
+258        options: Option<GetItemsOptions>,
+259    ) -> Result<Vec<MediaItem>, RepoError> {
+260        let result = self.online.get_items(parent_id, options).await?;
+261        if !result.items.is_empty() {
+262            self.offline.save_to_cache(parent_id, &result.items).await?;
+263        }
+264        Ok(result.items)
+265    }
+266
+267    /// Browse downloaded content only — the dedicated Downloads surface.
+268    ///
+269    /// Bypasses the cache/server merge entirely and reads the offline repository
+270    /// directly, so an empty result is authoritative ("nothing downloaded here")
+271    /// and never falls through to the server (DR-080). Available online too — a
+272    /// user who is reachable still wants to browse what's on the device.
+273    ///
+274    /// TRACES: UR-055 | DR-082, DR-083
+275    pub async fn get_downloaded_items(
+276        &self,
+277        parent_id: &str,
+278        options: Option<GetItemsOptions>,
+279    ) -> Result<SearchResult, RepoError> {
+280        // Deliberately *not* filtered by the user's hidden folders: this surface
+281        // manages what is on the device, and hiding a download would leave the
+282        // user unable to delete a file they can still see the disk usage of.
+283        // TRACES: UR-076 | DR-209
+284        self.offline.get_downloaded_items(parent_id, options).await
+285    }
+286
+287    /// Libraries that contain downloaded content (offline-only, authoritative).
+288    ///
+289    /// TRACES: UR-055 | DR-082
+290    pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
+291        self.offline.get_downloaded_libraries().await
+292    }
+293
+294    /// On-disk usage of downloaded content, for the disk-usage display.
+295    ///
+296    /// TRACES: UR-056 | DR-085
+297    pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
+298        self.offline.get_download_disk_usage().await
+299    }
+300
+301    /// Search only the live Jellyfin server (full library).
+302    pub async fn search_server_only(
+303        &self,
+304        query: &str,
+305        options: Option<SearchOptions>,
+306    ) -> Result<SearchResult, RepoError> {
+307        self.online
+308            .search(query, options)
+309            .await
+310            .map(ExcludeHidden::without_excluded)
+311    }
+312
+313    /// Merge cache and server search results into a single de-duplicated list.
+314    ///
+315    /// Ordering: local (cached/downloaded) items first, then server-only items
+316    /// appended. On a duplicate `id`, the server's item wins (fresher, more
+317    /// complete metadata) but keeps the local item's earlier position.
+318    pub fn merge_search_results(cache: SearchResult, server: SearchResult) -> SearchResult {
+319        use std::collections::HashMap;
+320
+321        // Index server items by id so we can (a) override duplicates with the
+322        // server's metadata and (b) know which server items are brand new.
+323        let mut server_by_id: HashMap<String, MediaItem> = HashMap::new();
+324        let mut server_order: Vec<String> = Vec::with_capacity(server.items.len());
+325        for item in server.items {
+326            if !server_by_id.contains_key(&item.id) {
+327                server_order.push(item.id.clone());
+328            }
+329            server_by_id.insert(item.id.clone(), item);
+330        }
+331
+332        let mut items: Vec<MediaItem> = Vec::new();
+333        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
+334
+335        // Local items first, in their original order. If the server also
+336        // returned this item, take the server's copy (newer metadata).
+337        for local in cache.items {
+338            if !seen.insert(local.id.clone()) {
+339                continue;
+340            }
+341            match server_by_id.remove(&local.id) {
+342                Some(server_item) => items.push(server_item),
+343                None => items.push(local),
+344            }
+345        }
+346
+347        // Then append server-only items, preserving the server's order.
+348        for id in server_order {
+349            if let Some(server_item) = server_by_id.remove(&id) {
+350                if seen.insert(id) {
+351                    items.push(server_item);
+352                }
+353            }
+354        }
+355
+356        let total_record_count = items.len();
+357        SearchResult {
+358            items,
+359            total_record_count,
+360        }
+361    }
+362
+363    /// Cache-first query: try cache, fall back to server on miss.
+364    ///
+365    /// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
+366    /// 2. If cache has meaningful content → return immediately (fast path)
+367    /// 3. If cache is empty/stale → query server (fresh data)
+368    /// 4. If server fails → return cache even if empty (offline fallback)
+369    ///
+370    /// Both legs are passed through [`ExcludeHidden`] before the "does the cache
+371    /// have content?" question is asked. This is the single place the cache and
+372    /// server results of a cache-first query converge, so applying the user's
+373    /// browsing exclusions here covers every query built on it at once — and
+374    /// filtering *before* the content check is what makes a cache page holding
+375    /// nothing but hidden items fall through to the server instead of being
+376    /// served as an empty listing.
+377    ///
+378    /// @req: UR-002 - Access media when online or offline
+379    /// @req: DR-013 - Repository pattern for online/offline data access
+380    ///
+381    /// TRACES: UR-002, UR-076 | DR-013, DR-209
+382    async fn parallel_race<T, F1, F2>(
+383        &self,
+384        cache_future: F1,
+385        server_future: F2,
+386    ) -> Result<T, RepoError>
+387    where
+388        T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
+389        F1: std::future::Future<Output = Result<T, RepoError>> + Send,
+390        F2: std::future::Future<Output = Result<T, RepoError>> + Send,
+391    {
+392        // Try cache first (100ms timeout already applied by callers)
+393        let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
+394
+395        if let Ok(data) = &cache_result {
+396            if data.has_content() {
+397                debug!("[HybridRepo] Cache hit, returning immediately");
+398                return Ok(data.clone());
+399            }
+400        }
+401
+402        // Cache miss — fall back to server
+403        debug!("[HybridRepo] Cache miss, querying server");
+404        match server_future.await {
+405            Ok(data) => Ok(data.without_excluded()),
+406            Err(e) => {
+407                // Server failed, try to return cache even if empty
+408                cache_result.or(Err(e))
+409            }
+410        }
+411    }
+412
+413    /// [`Self::parallel_race`], plus a callback fired on the fast path so the
+414    /// caller can refresh the cache in the background.
+415    ///
+416    /// A plain cache hit answers from data that may be arbitrarily old, which
+417    /// is right for the *response* and wrong for what it leaves behind: per-user
+418    /// state (watch positions, favourites) only reaches the local tables when a
+419    /// server result is cached, so a surface that always hits cache never learns
+420    /// what another device did. `get_items` had a bespoke version of this; this
+421    /// is the same idea, reusable.
+422    ///
+423    /// The callback runs only on a cache hit — on a miss the server result is
+424    /// already being fetched and cached by the normal path.
+425    ///
+426    /// TRACES: UR-002, UR-025, UR-076 | DR-155, DR-209
+427    async fn race_with_refresh<T, F1, F2, R>(
+428        &self,
+429        cache_future: F1,
+430        server_future: F2,
+431        on_cache_hit: R,
+432    ) -> Result<T, RepoError>
+433    where
+434        T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
+435        F1: std::future::Future<Output = Result<T, RepoError>> + Send,
+436        F2: std::future::Future<Output = Result<T, RepoError>> + Send,
+437        R: FnOnce(),
+438    {
+439        let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
+440
+441        if let Ok(data) = &cache_result {
+442            if data.has_content() {
+443                debug!("[HybridRepo] Cache hit, returning immediately (refreshing in background)");
+444                on_cache_hit();
+445                return Ok(data.clone());
+446            }
+447        }
+448
+449        debug!("[HybridRepo] Cache miss, querying server");
+450        match server_future.await {
+451            Ok(data) => Ok(data.without_excluded()),
+452            Err(e) => cache_result.or(Err(e)),
+453        }
+454    }
+455
+456    /// Simple timeout wrapper for cache queries (100ms timeout)
+457    ///
+458    /// @req: DR-013 - Repository pattern (cache-first with timeout)
+459    async fn cache_with_timeout<T>(
+460        &self,
+461        future: impl std::future::Future<Output = Result<T, RepoError>> + Send,
+462    ) -> Result<T, RepoError> {
+463        timeout(Duration::from_millis(100), future)
+464            .await
+465            .unwrap_or_else(|_| {
+466                Err(RepoError::Database {
+467                    message: "Cache query timeout".to_string(),
+468                })
+469            })
+470    }
+471}
+472
+473#[async_trait]
+474impl MediaRepository for HybridRepository {
+475    async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
+476        // Cache-first (100ms). On a cache hit, refresh the cache from the server
+477        // in the background. On a miss, fetch from the server and persist so the
+478        // list is available on the next (possibly offline) startup.
+479        let cache_result = self.cache_with_timeout(self.offline.get_libraries()).await;
+480
+481        if let Ok(libs) = &cache_result {
+482            if libs.has_content() {
+483                debug!("[HybridRepo] Cache hit for libraries, returning immediately");
+484                let online = Arc::clone(&self.online);
+485                let offline = Arc::clone(&self.offline);
+486                tokio::spawn(async move {
+487                    if let Ok(server_libs) = online.get_libraries().await {
+488                        if !server_libs.is_empty() {
+489                            if let Err(e) = offline.save_libraries_to_cache(&server_libs).await {
+490                                warn!(
+491                                    "[HybridRepo] Background library cache update failed: {:?}",
+492                                    e
+493                                );
+494                            }
+495                        }
+496                    }
+497                });
+498                return cache_result;
+499            }
+500        }
+501
+502        // Cache miss — fetch from server and persist for offline use.
+503        match self.online.get_libraries().await {
+504            Ok(server_libs) => {
+505                if !server_libs.is_empty() {
+506                    if let Err(e) = self.offline.save_libraries_to_cache(&server_libs).await {
+507                        warn!(
+508                            "[HybridRepo] Failed to cache {} libraries: {:?}",
+509                            server_libs.len(),
+510                            e
+511                        );
+512                    }
+513                }
+514                Ok(server_libs)
+515            }
+516            Err(e) => cache_result.or(Err(e)),
+517        }
+518    }
+519
+520    async fn get_items(
+521        &self,
+522        parent_id: &str,
+523        options: Option<GetItemsOptions>,
+524    ) -> Result<SearchResult, RepoError> {
+525        let offline = Arc::clone(&self.offline);
+526        let offline_for_save = Arc::clone(&self.offline);
+527        let online = Arc::clone(&self.online);
+528        let parent_id = parent_id.to_string();
+529        let parent_id_clone = parent_id.clone();
+530        let parent_id_for_save = parent_id.clone();
+531        let opts_clone = options.clone();
+532
+533        // Start server request in background (non-blocking)
+534        let server_handle =
+535            tokio::spawn(async move { online.get_items(&parent_id_clone, options).await });
+536
+537        // Check cache first (fast, 100ms timeout).
+538        //
+539        // Exclusions are applied here rather than at each return below so the
+540        // "has content" decisions further down are made about what the user will
+541        // actually see. `get_items` is the one query that does not go through
+542        // `parallel_race` — it interleaves the downloads-only gate and a
+543        // background cache write — so it applies the filter itself.
+544        // TRACES: UR-076 | DR-209
+545        let cache_result = self
+546            .cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
+547            .await
+548            .map(ExcludeHidden::without_excluded);
+549
+550        // Downloads-only gate: when the "Show all server media" toggle is off
+551        // (offline), an empty offline result is authoritative — the user asked
+552        // for downloaded media only and this library has none. Return it as-is
+553        // rather than falling through to the server, which would re-pad the page
+554        // with the full catalog and re-defeat the filter (DR-080). When the flag
+555        // is on (the default, and always so while reachable) behaviour below is
+556        // unchanged, including the background cache refresh on a hit.
+557        if !crate::repository::offline::include_catalog_browse() {
+558            if let Ok(data) = &cache_result {
+559                debug!(
+560                    "[HybridRepo] Downloads-only gate: returning offline result ({} items) as authoritative for parent {}",
+561                    data.items.len(),
+562                    &parent_id_for_save[..8.min(parent_id_for_save.len())]
+563                );
+564                // Abort the in-flight server request; we won't use it.
+565                server_handle.abort();
+566                return Ok(data.clone());
+567            }
+568        }
+569
+570        // Cache hit: return immediately, update cache in background
+571        if let Ok(data) = &cache_result {
+572            if data.has_content() {
+573                debug!(
+574                    "[HybridRepo] Cache hit for get_items, returning immediately for parent {}",
+575                    &parent_id_for_save[..8.min(parent_id_for_save.len())]
+576                );
+577                // Background: save server result to cache when it arrives
+578                tokio::spawn(async move {
+579                    match server_handle.await {
+580                        Ok(Ok(server_data)) if !server_data.items.is_empty() => {
+581                            if let Err(e) = offline_for_save
+582                                .save_to_cache(&parent_id_for_save, &server_data.items)
+583                                .await
+584                            {
+585                                warn!("[HybridRepo] Background cache update failed: {:?}", e);
+586                            } else {
+587                                debug!(
+588                                    "[HybridRepo] Background updated {} cached items for parent {}",
+589                                    server_data.items.len(),
+590                                    &parent_id_for_save[..8.min(parent_id_for_save.len())]
+591                                );
+592                            }
+593                        }
+594                        _ => {} // Server failed or returned empty — keep existing cache
+595                    }
+596                });
+597                return Ok(data.clone());
+598            }
+599        }
+600
+601        // Cache miss — wait for server result
+602        match server_handle.await {
+603            Ok(Ok(server_data)) => {
+604                if !server_data.items.is_empty() {
+605                    let items_clone = server_data.items.clone();
+606                    tokio::spawn(async move {
+607                        if let Err(e) = offline_for_save
+608                            .save_to_cache(&parent_id_for_save, &items_clone)
+609                            .await
+610                        {
+611                            warn!(
+612                                "[HybridRepo] Failed to save {} items to cache: {:?}",
+613                                items_clone.len(),
+614                                e
+615                            );
+616                        } else {
+617                            debug!(
+618                                "[HybridRepo] Saved {} items to cache for parent {}",
+619                                items_clone.len(),
+620                                &parent_id_for_save[..8.min(parent_id_for_save.len())]
+621                            );
+622                        }
+623                    });
+624                }
+625                // The cache keeps the server's full page (above) — an exclusion
+626                // is a view preference and can be undone, so hiding items from
+627                // the *cache* would make un-hiding them require a re-crawl. Only
+628                // what is handed back is filtered.
+629                // TRACES: UR-076 | DR-209
+630                Ok(server_data.without_excluded())
+631            }
+632            Ok(Err(e)) => cache_result.or(Err(e)),
+633            Err(join_err) => cache_result.or(Err(RepoError::Network {
+634                message: format!("Server task failed: {}", join_err),
+635            })),
+636        }
+637    }
+638
+639    /// A single item, cache-first — and, on a cache hit, refreshed in the
+640    /// background so the stored copy keeps up with the server.
+641    ///
+642    /// The background refresh is what carries per-user state home: caching an
+643    /// item runs `mirror_user_data`, which is the only path by which a watch
+644    /// position set on another device reaches the local `user_data` row the
+645    /// resume check reads. Without it a cache hit returned this device's own
+646    /// stale position forever and cross-device resume silently did nothing —
+647    /// `get_items` already refreshes this way, so browsing a season worked
+648    /// while opening the episode directly did not.
+649    ///
+650    /// The refreshed value lands for the *next* read rather than this one: the
+651    /// point of the cache-first race is to answer immediately.
+652    ///
+653    /// TRACES: UR-025, UR-002 | DR-155 | UT-152
+654    async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
+655        let offline = Arc::clone(&self.offline);
+656        let online = Arc::clone(&self.online);
+657        let item_id = item_id.to_string();
+658        let item_id_clone = item_id.clone();
+659
+660        let cache_future = self.cache_with_timeout(async move { offline.get_item(&item_id).await });
+661
+662        let online_for_refresh = Arc::clone(&self.online);
+663        let offline_for_save = Arc::clone(&self.offline);
+664        let refresh_id = item_id_clone.clone();
+665        let on_cache_hit = move || {
+666            tokio::spawn(async move {
+667                match online_for_refresh.get_item(&refresh_id).await {
+668                    Ok(fresh) => {
+669                        // `save_to_cache` files the row under a parent; the item's
+670                        // own parent keeps it where a later listing expects it.
+671                        let parent = fresh
+672                            .parent_id
+673                            .clone()
+674                            .unwrap_or_else(|| "item".to_string());
+675                        if let Err(e) = offline_for_save.save_to_cache(&parent, &[fresh]).await {
+676                            debug!("[HybridRepo] Background item refresh failed: {:?}", e);
+677                        }
+678                    }
+679                    Err(e) => debug!("[HybridRepo] Background item refresh unavailable: {:?}", e),
+680                }
+681            });
+682        };
+683
+684        let server_future = async move { online.get_item(&item_id_clone).await };
+685
+686        self.race_with_refresh(cache_future, server_future, on_cache_hit)
+687            .await
+688    }
+689
+690    async fn get_latest_items(
+691        &self,
+692        parent_id: &str,
+693        limit: Option<usize>,
+694    ) -> Result<Vec<MediaItem>, RepoError> {
+695        let offline = Arc::clone(&self.offline);
+696        let online = Arc::clone(&self.online);
+697        let parent_id = parent_id.to_string();
+698        let parent_id_clone = parent_id.clone();
+699        let limit_clone = limit;
+700
+701        let cache_future = self
+702            .cache_with_timeout(async move { offline.get_latest_items(&parent_id, limit).await });
+703
+704        let server_future =
+705            async move { online.get_latest_items(&parent_id_clone, limit_clone).await };
+706
+707        self.parallel_race(cache_future, server_future).await
+708    }
+709
+710    async fn get_resume_items(
+711        &self,
+712        parent_id: Option<&str>,
+713        limit: Option<usize>,
+714    ) -> Result<Vec<MediaItem>, RepoError> {
+715        let offline = Arc::clone(&self.offline);
+716        let online = Arc::clone(&self.online);
+717        let parent_id_str = parent_id.map(|s| s.to_string());
+718        let parent_id_clone = parent_id_str.clone();
+719        let limit_clone = limit;
+720
+721        let cache_future = self.cache_with_timeout(async move {
+722            offline
+723                .get_resume_items(parent_id_str.as_deref(), limit)
+724                .await
+725        });
+726
+727        let server_future = async move {
+728            online
+729                .get_resume_items(parent_id_clone.as_deref(), limit_clone)
+730                .await
+731        };
+732
+733        self.parallel_race(cache_future, server_future).await
+734    }
+735
+736    async fn get_next_up_episodes(
+737        &self,
+738        series_id: Option<&str>,
+739        limit: Option<usize>,
+740    ) -> Result<Vec<MediaItem>, RepoError> {
+741        // Next up is dynamic, always fetch from server
+742        self.online
+743            .get_next_up_episodes(series_id, limit)
+744            .await
+745            .map(ExcludeHidden::without_excluded)
+746    }
+747
+748    async fn get_recently_played_audio(
+749        &self,
+750        limit: Option<usize>,
+751    ) -> Result<Vec<MediaItem>, RepoError> {
+752        let offline = Arc::clone(&self.offline);
+753        let online = Arc::clone(&self.online);
+754        let limit_clone = limit;
+755
+756        let cache_future =
+757            self.cache_with_timeout(async move { offline.get_recently_played_audio(limit).await });
+758
+759        let server_future = async move { online.get_recently_played_audio(limit_clone).await };
+760
+761        self.parallel_race(cache_future, server_future).await
+762    }
+763
+764    async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
+765        let offline = Arc::clone(&self.offline);
+766        let online = Arc::clone(&self.online);
+767        let limit_clone = limit;
+768
+769        let cache_future =
+770            self.cache_with_timeout(async move { offline.get_resume_movies(limit).await });
+771
+772        let server_future = async move { online.get_resume_movies(limit_clone).await };
+773
+774        self.parallel_race(cache_future, server_future).await
+775    }
+776
+777    async fn get_rediscover_albums(
+778        &self,
+779        parent_id: Option<&str>,
+780        limit: Option<usize>,
+781    ) -> Result<Vec<MediaItem>, RepoError> {
+782        let offline = Arc::clone(&self.offline);
+783        let online = Arc::clone(&self.online);
+784        let parent_id_owned = parent_id.map(|s| s.to_string());
+785        let parent_id_clone = parent_id_owned.clone();
+786
+787        let cache_future = self.cache_with_timeout(async move {
+788            offline
+789                .get_rediscover_albums(parent_id_owned.as_deref(), limit)
+790                .await
+791        });
+792
+793        let server_future = async move {
+794            online
+795                .get_rediscover_albums(parent_id_clone.as_deref(), limit)
+796                .await
+797        };
+798
+799        self.parallel_race(cache_future, server_future).await
+800    }
+801
+802    async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
+803        // Cache-first (100ms). On a cache hit, refresh the cached genre catalog
+804        // from the server in the background. On a miss, fetch from the server and
+805        // persist so the full genre list is available offline. Mirrors
+806        // get_libraries — NOT parallel_race, whose "any non-empty cache wins"
+807        // rule would pin genres to whatever sparse set the local albums yield.
+808        let parent_id_str = parent_id.map(|s| s.to_string());
+809
+810        let cache_offline = Arc::clone(&self.offline);
+811        let cache_pid = parent_id_str.clone();
+812        let cache_result = self
+813            .cache_with_timeout(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
+814            .await;
+815
+816        if let Ok(genres) = &cache_result {
+817            if genres.has_content() {
+818                debug!("[HybridRepo] Cache hit for genres, returning immediately");
+819                let online = Arc::clone(&self.online);
+820                let offline = Arc::clone(&self.offline);
+821                let pid = parent_id_str.clone();
+822                tokio::spawn(async move {
+823                    if let Ok(server_genres) = online.get_genres(pid.as_deref()).await {
+824                        if !server_genres.is_empty() {
+825                            if let Err(e) = offline
+826                                .save_genres_to_cache(pid.as_deref(), &server_genres)
+827                                .await
+828                            {
+829                                warn!("[HybridRepo] Background genre cache update failed: {:?}", e);
+830                            }
+831                        }
+832                    }
+833                });
+834                return cache_result;
+835            }
+836        }
+837
+838        // Cache miss — fetch from server and persist for offline use.
+839        match self.online.get_genres(parent_id_str.as_deref()).await {
+840            Ok(server_genres) => {
+841                if !server_genres.is_empty() {
+842                    if let Err(e) = self
+843                        .offline
+844                        .save_genres_to_cache(parent_id_str.as_deref(), &server_genres)
+845                        .await
+846                    {
+847                        warn!(
+848                            "[HybridRepo] Failed to cache {} genres: {:?}",
+849                            server_genres.len(),
+850                            e
+851                        );
+852                    }
+853                }
+854                Ok(server_genres)
+855            }
+856            Err(e) => cache_result.or(Err(e)),
+857        }
+858    }
+859
+860    async fn search(
+861        &self,
+862        query: &str,
+863        options: Option<SearchOptions>,
+864    ) -> Result<SearchResult, RepoError> {
+865        let offline = Arc::clone(&self.offline);
+866        let online = Arc::clone(&self.online);
+867        let query = query.to_string();
+868        let query_clone = query.clone();
+869        let opts_clone = options.clone();
+870
+871        let cache_future =
+872            self.cache_with_timeout(async move { offline.search(&query, opts_clone).await });
+873
+874        let server_future = async move { online.search(&query_clone, options).await };
+875
+876        self.parallel_race(cache_future, server_future).await
+877    }
+878
+879    async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
+880        // Playback info requires server communication for transcoding decisions
+881        self.online.get_playback_info(item_id).await
+882    }
+883
+884    async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
+885        // Stream URLs require server communication - delegate to online repository
+886        self.online.get_audio_stream_url(item_id).await
+887    }
+888
+889    async fn get_audio_only_stream_url_for_video(
+890        &self,
+891        item_id: &str,
+892        media_source_id: Option<&str>,
+893        start_time_seconds: Option<f64>,
+894        audio_stream_index: Option<i32>,
+895    ) -> Result<String, RepoError> {
+896        // Audio-only transcode of a video requires the server - delegate to online.
+897        self.online
+898            .build_audio_only_stream_url_for_video(
+899                item_id,
+900                media_source_id,
+901                start_time_seconds,
+902                audio_stream_index,
+903            )
+904            .await
+905    }
+906
+907    async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
+908        // Live TV requires server communication - delegate to online repository
+909        self.online
+910            .get_live_tv_channels()
+911            .await
+912            .map(ExcludeHidden::without_excluded)
+913    }
+914
+915    async fn get_channels(&self) -> Result<SearchResult, RepoError> {
+916        // Plugin channels require server communication - delegate to online repository
+917        self.online
+918            .get_channels()
+919            .await
+920            .map(ExcludeHidden::without_excluded)
+921    }
+922
+923    async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
+924        // Opening a live stream requires server communication - delegate to online
+925        self.online.open_live_stream(item_id).await
+926    }
+927
+928    async fn report_playback_start(
+929        &self,
+930        item_id: &str,
+931        position_ticks: i64,
+932    ) -> Result<(), RepoError> {
+933        // Playback reporting goes directly to server
+934        self.online
+935            .report_playback_start(item_id, position_ticks)
+936            .await
+937    }
+938
+939    async fn report_playback_progress(
+940        &self,
+941        item_id: &str,
+942        position_ticks: i64,
+943    ) -> Result<(), RepoError> {
+944        // Playback reporting goes directly to server
+945        self.online
+946            .report_playback_progress(item_id, position_ticks)
+947            .await
+948    }
+949
+950    async fn report_playback_stopped(
+951        &self,
+952        item_id: &str,
+953        position_ticks: i64,
+954    ) -> Result<(), RepoError> {
+955        // Playback reporting goes directly to server
+956        self.online
+957            .report_playback_stopped(item_id, position_ticks)
+958            .await
+959    }
+960
+961    fn get_image_url(
+962        &self,
+963        item_id: &str,
+964        image_type: ImageType,
+965        options: Option<ImageOptions>,
+966    ) -> String {
+967        // Always use online URL for images (thumbnail cache handles offline)
+968        self.online.get_image_url(item_id, image_type, options)
+969    }
+970
+971    fn get_subtitle_url(
+972        &self,
+973        item_id: &str,
+974        media_source_id: &str,
+975        stream_index: i32,
+976        format: &str,
+977    ) -> String {
+978        // Always use online URL for subtitles
+979        self.online
+980            .get_subtitle_url(item_id, media_source_id, stream_index, format)
+981    }
+982
+983    fn get_video_download_url(
+984        &self,
+985        item_id: &str,
+986        quality: &str,
+987        media_source_id: Option<&str>,
+988        source_audio_codec: Option<&str>,
+989    ) -> String {
+990        // Always use online URL for downloads
+991        self.online
+992            .get_video_download_url(item_id, quality, media_source_id, source_audio_codec)
+993    }
+994
+995    async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
+996        // Write operations go directly to server
+997        self.online.mark_favorite(item_id).await
+998    }
+999
+1000    async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
+1001        // Write operations go directly to server
+1002        self.online.unmark_favorite(item_id).await
+1003    }
+1004
+1005    async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
+1006        // Write operations go directly to server
+1007        self.online.clear_watch_history(item_id).await
+1008    }
+1009
+1010    async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
+1011        // Write operations go directly to server
+1012        self.online.mark_played(item_id).await
+1013    }
+1014
+1015    async fn get_person(&self, person_id: &str) -> Result<MediaItem, 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
+1021        let cache_future =
+1022            self.cache_with_timeout(async move { offline.get_person(&person_id).await });
+1023
+1024        let server_future = async move { online.get_person(&person_id_clone).await };
+1025
+1026        self.parallel_race(cache_future, server_future).await
+1027    }
+1028
+1029    async fn get_items_by_person(
+1030        &self,
+1031        person_id: &str,
+1032        options: Option<GetItemsOptions>,
+1033    ) -> Result<SearchResult, RepoError> {
+1034        let offline = Arc::clone(&self.offline);
+1035        let online = Arc::clone(&self.online);
+1036        let person_id = person_id.to_string();
+1037        let person_id_clone = person_id.clone();
+1038        let opts_clone = options.clone();
+1039
+1040        let cache_future = self.cache_with_timeout(async move {
+1041            offline.get_items_by_person(&person_id, opts_clone).await
+1042        });
+1043
+1044        let server_future =
+1045            async move { online.get_items_by_person(&person_id_clone, options).await };
+1046
+1047        self.parallel_race(cache_future, server_future).await
+1048    }
+1049
+1050    /// TRACES: UR-067 | DR-115
+1051    async fn get_favorites(
+1052        &self,
+1053        scope: SearchScope,
+1054        options: Option<GetItemsOptions>,
+1055    ) -> Result<SearchResult, RepoError> {
+1056        let cache_result = self.get_favorites_cache_only(scope, options.clone()).await;
+1057
+1058        // Downloads-only gate: with "Show all server media" off, an empty local
+1059        // result means "nothing favourited is on this device" and is
+1060        // authoritative. Falling through to the server here would re-pad the
+1061        // page with the full favourited catalog and defeat the filter (DR-080).
+1062        if !crate::repository::offline::include_catalog_browse() {
+1063            if let Ok(data) = &cache_result {
+1064                return Ok(data.clone());
+1065            }
+1066        }
+1067
+1068        if let Ok(data) = &cache_result {
+1069            if data.has_content() {
+1070                return Ok(data.clone());
+1071            }
+1072        }
+1073
+1074        // Cache miss — answer from the server, *saving through* on the way back.
+1075        // Every other read path persists what it fetches; skipping it here would
+1076        // mean the favourites page re-queries the server on every visit and the
+1077        // offline mirror (DR-114) never learns about favourites marked
+1078        // elsewhere, since this path is what fills it on a fresh install.
+1079        match self.get_favorites_server_only(scope, options).await {
+1080            Ok(data) => Ok(data),
+1081            Err(e) => cache_result.or(Err(e)),
+1082        }
+1083    }
+1084
+1085    async fn get_similar_items(
+1086        &self,
+1087        item_id: &str,
+1088        limit: Option<usize>,
+1089    ) -> Result<SearchResult, RepoError> {
+1090        let offline = Arc::clone(&self.offline);
+1091        let online = Arc::clone(&self.online);
+1092        let item_id = item_id.to_string();
+1093        let item_id_clone = item_id.clone();
+1094
+1095        let cache_future = self
+1096            .cache_with_timeout(async move { offline.get_similar_items(&item_id, limit).await });
+1097
+1098        let server_future = async move { online.get_similar_items(&item_id_clone, limit).await };
+1099
+1100        self.parallel_race(cache_future, server_future).await
+1101    }
+1102
+1103    // ===== Playlist Methods =====
+1104
+1105    async fn create_playlist(
+1106        &self,
+1107        name: &str,
+1108        item_ids: &[String],
+1109    ) -> Result<PlaylistCreatedResult, RepoError> {
+1110        // Write operation - delegate directly to server
+1111        self.online.create_playlist(name, item_ids).await
+1112    }
+1113
+1114    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
+1115        // Write operation - delegate directly to server
+1116        self.online.delete_playlist(playlist_id).await
+1117    }
+1118
+1119    async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
+1120        // Write operation - delegate directly to server
+1121        self.online.rename_playlist(playlist_id, name).await
+1122    }
+1123
+1124    async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
+1125        let offline = Arc::clone(&self.offline);
+1126        let offline_for_save = Arc::clone(&self.offline);
+1127        let online = Arc::clone(&self.online);
+1128        let playlist_id = playlist_id.to_string();
+1129        let playlist_id_clone = playlist_id.clone();
+1130        let playlist_id_for_save = playlist_id.clone();
+1131
+1132        // Start server request in background (non-blocking)
+1133        let server_handle =
+1134            tokio::spawn(async move { online.get_playlist_items(&playlist_id_clone).await });
+1135
+1136        // Check cache first (fast, 100ms timeout)
+1137        let cache_result = self
+1138            .cache_with_timeout(async move { offline.get_playlist_items(&playlist_id).await })
+1139            .await;
+1140
+1141        // Cache hit: return immediately, update cache in background
+1142        if let Ok(data) = &cache_result {
+1143            if data.has_content() {
+1144                debug!("[HybridRepo] Cache hit for playlist items, returning immediately");
+1145                tokio::spawn(async move {
+1146                    if let Ok(Ok(server_entries)) = server_handle.await {
+1147                        if let Err(e) = offline_for_save
+1148                            .save_playlist_items_to_cache(&playlist_id_for_save, &server_entries)
+1149                            .await
+1150                        {
+1151                            warn!("[HybridRepo] Failed to update playlist cache: {:?}", e);
+1152                        }
+1153                    }
+1154                });
+1155                return cache_result;
+1156            }
+1157        }
+1158
+1159        // Cache miss — wait for server result
+1160        match server_handle.await {
+1161            Ok(Ok(entries)) => {
+1162                let entries_clone = entries.clone();
+1163                tokio::spawn(async move {
+1164                    if let Err(e) = offline_for_save
+1165                        .save_playlist_items_to_cache(&playlist_id_for_save, &entries_clone)
+1166                        .await
+1167                    {
+1168                        warn!(
+1169                            "[HybridRepo] Failed to save playlist items to cache: {:?}",
+1170                            e
+1171                        );
+1172                    }
+1173                });
+1174                Ok(entries)
+1175            }
+1176            Ok(Err(e)) => cache_result.or(Err(e)),
+1177            Err(join_err) => cache_result.or(Err(RepoError::Network {
+1178                message: format!("Server task failed: {}", join_err),
+1179            })),
+1180        }
+1181    }
+1182
+1183    async fn add_to_playlist(
+1184        &self,
+1185        playlist_id: &str,
+1186        item_ids: &[String],
+1187    ) -> Result<(), RepoError> {
+1188        // Write operation - delegate directly to server
+1189        self.online.add_to_playlist(playlist_id, item_ids).await
+1190    }
+1191
+1192    async fn remove_from_playlist(
+1193        &self,
+1194        playlist_id: &str,
+1195        entry_ids: &[String],
+1196    ) -> Result<(), RepoError> {
+1197        // Write operation - delegate directly to server
+1198        self.online
+1199            .remove_from_playlist(playlist_id, entry_ids)
+1200            .await
+1201    }
+1202
+1203    async fn move_playlist_item(
+1204        &self,
+1205        playlist_id: &str,
+1206        item_id: &str,
+1207        new_index: u32,
+1208    ) -> Result<(), RepoError> {
+1209        // Write operation - delegate directly to server
+1210        self.online
+1211            .move_playlist_item(playlist_id, item_id, new_index)
+1212            .await
+1213    }
+1214}
+1215
+1216#[cfg(test)]
+1217mod tests {
+1218    // `GATE_TEST_LOCK` below serialises the tests that flip the process-global
+1219    // `INCLUDE_CATALOG_BROWSE` flag, so its guard is deliberately held across
+1220    // the `.await` of the repository call under test — that await *is* the
+1221    // critical section. This is not the production deadlock hazard the lint
+1222    // targets: the lock is test-only, uncontended outside these tests, and each
+1223    // `#[tokio::test]` runs on its own single-threaded runtime, so a held guard
+1224    // cannot block another task on the same worker. Restructuring around it
+1225    // would reintroduce the flag race the lock exists to prevent.
+1226    #![allow(clippy::await_holding_lock)]
+1227
+1228    use super::*;
+1229    use std::sync::Mutex;
+1230
+1231    /// Mock offline repository that tracks queries and saves
+1232    struct MockOfflineRepo {
+1233        items: Arc<Mutex<Vec<MediaItem>>>,
+1234        query_count: Arc<Mutex<usize>>,
+1235        save_count: Arc<Mutex<usize>>,
+1236    }
+1237
+1238    impl MockOfflineRepo {
+1239        fn new() -> Self {
+1240            Self {
+1241                items: Arc::new(Mutex::new(Vec::new())),
+1242                query_count: Arc::new(Mutex::new(0)),
+1243                save_count: Arc::new(Mutex::new(0)),
+1244            }
+1245        }
+1246
+1247        fn get_query_count(&self) -> usize {
+1248            *self.query_count.lock_safe()
+1249        }
+1250
+1251        fn get_save_count(&self) -> usize {
+1252            *self.save_count.lock_safe()
+1253        }
+1254
+1255        async fn save_to_cache(
+1256            &self,
+1257            _parent_id: &str,
+1258            items: &[MediaItem],
+1259        ) -> Result<usize, RepoError> {
+1260            *self.save_count.lock_safe() += 1;
+1261            *self.items.lock_safe() = items.to_vec();
+1262            Ok(items.len())
+1263        }
+1264    }
+1265
+1266    #[async_trait]
+1267    impl MediaRepository for MockOfflineRepo {
+1268        async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
+1269            unimplemented!()
+1270        }
+1271
+1272        async fn get_items(
+1273            &self,
+1274            _parent_id: &str,
+1275            _options: Option<GetItemsOptions>,
+1276        ) -> Result<SearchResult, RepoError> {
+1277            *self.query_count.lock_safe() += 1;
+1278            let items = self.items.lock_safe().clone();
+1279            let count = items.len();
+1280            Ok(SearchResult {
+1281                items,
+1282                total_record_count: count,
+1283            })
+1284        }
+1285
+1286        async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
+1287            unimplemented!()
+1288        }
+1289
+1290        async fn get_latest_items(
+1291            &self,
+1292            _parent_id: &str,
+1293            _limit: Option<usize>,
+1294        ) -> Result<Vec<MediaItem>, RepoError> {
+1295            unimplemented!()
+1296        }
+1297
+1298        async fn get_resume_items(
+1299            &self,
+1300            _parent_id: Option<&str>,
+1301            _limit: Option<usize>,
+1302        ) -> Result<Vec<MediaItem>, RepoError> {
+1303            unimplemented!()
+1304        }
+1305
+1306        async fn get_next_up_episodes(
+1307            &self,
+1308            _series_id: Option<&str>,
+1309            _limit: Option<usize>,
+1310        ) -> Result<Vec<MediaItem>, RepoError> {
+1311            unimplemented!()
+1312        }
+1313
+1314        async fn get_recently_played_audio(
+1315            &self,
+1316            _limit: Option<usize>,
+1317        ) -> Result<Vec<MediaItem>, RepoError> {
+1318            unimplemented!()
+1319        }
+1320
+1321        async fn get_resume_movies(
+1322            &self,
+1323            _limit: Option<usize>,
+1324        ) -> Result<Vec<MediaItem>, RepoError> {
+1325            unimplemented!()
+1326        }
+1327
+1328        async fn get_rediscover_albums(
+1329            &self,
+1330            _parent_id: Option<&str>,
+1331            _limit: Option<usize>,
+1332        ) -> Result<Vec<MediaItem>, RepoError> {
+1333            unimplemented!()
+1334        }
+1335
+1336        async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
+1337            unimplemented!()
+1338        }
+1339
+1340        async fn search(
+1341            &self,
+1342            _query: &str,
+1343            _options: Option<SearchOptions>,
+1344        ) -> Result<SearchResult, RepoError> {
+1345            unimplemented!()
+1346        }
+1347
+1348        async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
+1349            unimplemented!()
+1350        }
+1351
+1352        async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
+1353            unimplemented!()
+1354        }
+1355
+1356        async fn get_audio_only_stream_url_for_video(
+1357            &self,
+1358            _item_id: &str,
+1359            _media_source_id: Option<&str>,
+1360            _start_time_seconds: Option<f64>,
+1361            _audio_stream_index: Option<i32>,
+1362        ) -> Result<String, RepoError> {
+1363            unimplemented!()
+1364        }
+1365
+1366        async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
+1367            unimplemented!()
+1368        }
+1369
+1370        async fn get_channels(&self) -> Result<SearchResult, RepoError> {
+1371            unimplemented!()
+1372        }
+1373
+1374        async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
+1375            unimplemented!()
+1376        }
+1377
+1378        async fn report_playback_start(
+1379            &self,
+1380            _item_id: &str,
+1381            _position_ticks: i64,
+1382        ) -> Result<(), RepoError> {
+1383            unimplemented!()
+1384        }
+1385
+1386        async fn report_playback_progress(
+1387            &self,
+1388            _item_id: &str,
+1389            _position_ticks: i64,
+1390        ) -> Result<(), RepoError> {
+1391            unimplemented!()
+1392        }
+1393
+1394        async fn report_playback_stopped(
+1395            &self,
+1396            _item_id: &str,
+1397            _position_ticks: i64,
+1398        ) -> Result<(), RepoError> {
+1399            unimplemented!()
+1400        }
+1401
+1402        fn get_image_url(
+1403            &self,
+1404            _item_id: &str,
+1405            _image_type: ImageType,
+1406            _options: Option<ImageOptions>,
+1407        ) -> String {
+1408            unimplemented!()
+1409        }
+1410
+1411        fn get_subtitle_url(
+1412            &self,
+1413            _item_id: &str,
+1414            _media_source_id: &str,
+1415            _stream_index: i32,
+1416            _format: &str,
+1417        ) -> String {
+1418            unimplemented!()
+1419        }
+1420
+1421        fn get_video_download_url(
+1422            &self,
+1423            _item_id: &str,
+1424            _quality: &str,
+1425            _media_source_id: Option<&str>,
+1426            _source_audio_codec: Option<&str>,
+1427        ) -> String {
+1428            unimplemented!()
+1429        }
+1430
+1431        async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
+1432            unimplemented!()
+1433        }
+1434
+1435        async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
+1436            unimplemented!()
+1437        }
+1438
+1439        async fn get_favorites(
+1440            &self,
+1441            _scope: SearchScope,
+1442            _options: Option<GetItemsOptions>,
+1443        ) -> Result<SearchResult, RepoError> {
+1444            unimplemented!()
+1445        }
+1446
+1447        async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
+1448            unimplemented!()
+1449        }
+1450
+1451        async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
+1452            unimplemented!()
+1453        }
+1454
+1455        async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
+1456            unimplemented!()
+1457        }
+1458
+1459        async fn get_items_by_person(
+1460            &self,
+1461            _person_id: &str,
+1462            _options: Option<GetItemsOptions>,
+1463        ) -> Result<SearchResult, RepoError> {
+1464            unimplemented!()
+1465        }
+1466
+1467        async fn get_similar_items(
+1468            &self,
+1469            _item_id: &str,
+1470            _limit: Option<usize>,
+1471        ) -> Result<SearchResult, RepoError> {
+1472            unimplemented!()
+1473        }
+1474
+1475        async fn create_playlist(
+1476            &self,
+1477            _name: &str,
+1478            _item_ids: &[String],
+1479        ) -> Result<PlaylistCreatedResult, RepoError> {
+1480            unimplemented!()
+1481        }
+1482
+1483        async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
+1484            unimplemented!()
+1485        }
+1486
+1487        async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
+1488            unimplemented!()
+1489        }
+1490
+1491        async fn get_playlist_items(
+1492            &self,
+1493            _playlist_id: &str,
+1494        ) -> Result<Vec<PlaylistEntry>, RepoError> {
+1495            unimplemented!()
+1496        }
+1497
+1498        async fn add_to_playlist(
+1499            &self,
+1500            _playlist_id: &str,
+1501            _item_ids: &[String],
+1502        ) -> Result<(), RepoError> {
+1503            unimplemented!()
+1504        }
+1505
+1506        async fn remove_from_playlist(
+1507            &self,
+1508            _playlist_id: &str,
+1509            _entry_ids: &[String],
+1510        ) -> Result<(), RepoError> {
+1511            unimplemented!()
+1512        }
+1513
+1514        async fn move_playlist_item(
+1515            &self,
+1516            _playlist_id: &str,
+1517            _item_id: &str,
+1518            _new_index: u32,
+1519        ) -> Result<(), RepoError> {
+1520            unimplemented!()
+1521        }
+1522    }
+1523
+1524    /// Mock online repository that returns predefined items
+1525    struct MockOnlineRepo {
+1526        items: Vec<MediaItem>,
+1527        query_count: Arc<Mutex<usize>>,
+1528    }
+1529
+1530    impl MockOnlineRepo {
+1531        fn new(items: Vec<MediaItem>) -> Self {
+1532            Self {
+1533                items,
+1534                query_count: Arc::new(Mutex::new(0)),
+1535            }
+1536        }
+1537
+1538        fn get_query_count(&self) -> usize {
+1539            *self.query_count.lock_safe()
+1540        }
+1541    }
+1542
+1543    #[async_trait]
+1544    impl MediaRepository for MockOnlineRepo {
+1545        async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
+1546            unimplemented!()
+1547        }
+1548
+1549        async fn get_items(
+1550            &self,
+1551            _parent_id: &str,
+1552            _options: Option<GetItemsOptions>,
+1553        ) -> Result<SearchResult, RepoError> {
+1554            *self.query_count.lock_safe() += 1;
+1555            Ok(SearchResult {
+1556                items: self.items.clone(),
+1557                total_record_count: self.items.len(),
+1558            })
+1559        }
+1560
+1561        async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
+1562            unimplemented!()
+1563        }
+1564
+1565        async fn get_latest_items(
+1566            &self,
+1567            _parent_id: &str,
+1568            _limit: Option<usize>,
+1569        ) -> Result<Vec<MediaItem>, RepoError> {
+1570            unimplemented!()
+1571        }
+1572
+1573        async fn get_resume_items(
+1574            &self,
+1575            _parent_id: Option<&str>,
+1576            _limit: Option<usize>,
+1577        ) -> Result<Vec<MediaItem>, RepoError> {
+1578            unimplemented!()
+1579        }
+1580
+1581        async fn get_next_up_episodes(
+1582            &self,
+1583            _series_id: Option<&str>,
+1584            _limit: Option<usize>,
+1585        ) -> Result<Vec<MediaItem>, RepoError> {
+1586            unimplemented!()
+1587        }
+1588
+1589        async fn get_recently_played_audio(
+1590            &self,
+1591            _limit: Option<usize>,
+1592        ) -> Result<Vec<MediaItem>, RepoError> {
+1593            unimplemented!()
+1594        }
+1595
+1596        async fn get_resume_movies(
+1597            &self,
+1598            _limit: Option<usize>,
+1599        ) -> Result<Vec<MediaItem>, RepoError> {
+1600            unimplemented!()
+1601        }
+1602
+1603        async fn get_rediscover_albums(
+1604            &self,
+1605            _parent_id: Option<&str>,
+1606            _limit: Option<usize>,
+1607        ) -> Result<Vec<MediaItem>, RepoError> {
+1608            unimplemented!()
+1609        }
+1610
+1611        async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
+1612            unimplemented!()
+1613        }
+1614
+1615        async fn search(
+1616            &self,
+1617            _query: &str,
+1618            _options: Option<SearchOptions>,
+1619        ) -> Result<SearchResult, RepoError> {
+1620            unimplemented!()
+1621        }
+1622
+1623        async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
+1624            unimplemented!()
+1625        }
+1626
+1627        async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
+1628            unimplemented!()
+1629        }
+1630
+1631        async fn get_audio_only_stream_url_for_video(
+1632            &self,
+1633            _item_id: &str,
+1634            _media_source_id: Option<&str>,
+1635            _start_time_seconds: Option<f64>,
+1636            _audio_stream_index: Option<i32>,
+1637        ) -> Result<String, RepoError> {
+1638            unimplemented!()
+1639        }
+1640
+1641        async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
+1642            unimplemented!()
+1643        }
+1644
+1645        async fn get_channels(&self) -> Result<SearchResult, RepoError> {
+1646            unimplemented!()
+1647        }
+1648
+1649        async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
+1650            unimplemented!()
+1651        }
+1652
+1653        async fn report_playback_start(
+1654            &self,
+1655            _item_id: &str,
+1656            _position_ticks: i64,
+1657        ) -> Result<(), RepoError> {
+1658            unimplemented!()
+1659        }
+1660
+1661        async fn report_playback_progress(
+1662            &self,
+1663            _item_id: &str,
+1664            _position_ticks: i64,
+1665        ) -> Result<(), RepoError> {
+1666            unimplemented!()
+1667        }
+1668
+1669        async fn report_playback_stopped(
+1670            &self,
+1671            _item_id: &str,
+1672            _position_ticks: i64,
+1673        ) -> Result<(), RepoError> {
+1674            unimplemented!()
+1675        }
+1676
+1677        fn get_image_url(
+1678            &self,
+1679            _item_id: &str,
+1680            _image_type: ImageType,
+1681            _options: Option<ImageOptions>,
+1682        ) -> String {
+1683            unimplemented!()
+1684        }
+1685
+1686        fn get_subtitle_url(
+1687            &self,
+1688            _item_id: &str,
+1689            _media_source_id: &str,
+1690            _stream_index: i32,
+1691            _format: &str,
+1692        ) -> String {
+1693            unimplemented!()
+1694        }
+1695
+1696        fn get_video_download_url(
+1697            &self,
+1698            _item_id: &str,
+1699            _quality: &str,
+1700            _media_source_id: Option<&str>,
+1701            _source_audio_codec: Option<&str>,
+1702        ) -> String {
+1703            unimplemented!()
+1704        }
+1705
+1706        async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
+1707            unimplemented!()
+1708        }
+1709
+1710        async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
+1711            unimplemented!()
+1712        }
+1713
+1714        async fn get_favorites(
+1715            &self,
+1716            _scope: SearchScope,
+1717            _options: Option<GetItemsOptions>,
+1718        ) -> Result<SearchResult, RepoError> {
+1719            unimplemented!()
+1720        }
+1721
+1722        async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
+1723            unimplemented!()
+1724        }
+1725
+1726        async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
+1727            unimplemented!()
+1728        }
+1729
+1730        async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
+1731            unimplemented!()
+1732        }
+1733
+1734        async fn get_items_by_person(
+1735            &self,
+1736            _person_id: &str,
+1737            _options: Option<GetItemsOptions>,
+1738        ) -> Result<SearchResult, RepoError> {
+1739            unimplemented!()
+1740        }
+1741
+1742        async fn get_similar_items(
+1743            &self,
+1744            _item_id: &str,
+1745            _limit: Option<usize>,
+1746        ) -> Result<SearchResult, RepoError> {
+1747            unimplemented!()
+1748        }
+1749
+1750        async fn create_playlist(
+1751            &self,
+1752            _name: &str,
+1753            _item_ids: &[String],
+1754        ) -> Result<PlaylistCreatedResult, RepoError> {
+1755            unimplemented!()
+1756        }
+1757
+1758        async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
+1759            unimplemented!()
+1760        }
+1761
+1762        async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
+1763            unimplemented!()
+1764        }
+1765
+1766        async fn get_playlist_items(
+1767            &self,
+1768            _playlist_id: &str,
+1769        ) -> Result<Vec<PlaylistEntry>, RepoError> {
+1770            unimplemented!()
+1771        }
+1772
+1773        async fn add_to_playlist(
+1774            &self,
+1775            _playlist_id: &str,
+1776            _item_ids: &[String],
+1777        ) -> Result<(), RepoError> {
+1778            unimplemented!()
+1779        }
+1780
+1781        async fn remove_from_playlist(
+1782            &self,
+1783            _playlist_id: &str,
+1784            _entry_ids: &[String],
+1785        ) -> Result<(), RepoError> {
+1786            unimplemented!()
+1787        }
+1788
+1789        async fn move_playlist_item(
+1790            &self,
+1791            _playlist_id: &str,
+1792            _item_id: &str,
+1793            _new_index: u32,
+1794        ) -> Result<(), RepoError> {
+1795            unimplemented!()
+1796        }
+1797    }
+1798
+1799    fn create_test_item(id: &str, name: &str) -> MediaItem {
+1800        MediaItem {
+1801            id: id.to_string(),
+1802            name: name.to_string(),
+1803            item_type: "Movie".to_string(),
+1804            kind: crate::domain::MediaKind::Movie,
+1805            is_folder: false,
+1806            server_id: "test-server".to_string(),
+1807            parent_id: Some("parent-123".to_string()),
+1808            library_id: Some("library-456".to_string()),
+1809            overview: Some("Test overview".to_string()),
+1810            genres: Some(vec!["Action".to_string(), "Adventure".to_string()]),
+1811            runtime_ticks: Some(7200000000),
+1812            duration_ms: Some(720000),
+1813            production_year: Some(2024),
+1814            premiere_date: None,
+1815            community_rating: Some(8.5),
+1816            official_rating: Some("PG-13".to_string()),
+1817            primary_image_tag: Some("image-tag-123".to_string()),
+1818            image_id: Some("image-tag-123".to_string()),
+1819            backdrop_image_tags: Some(vec!["backdrop-1".to_string()]),
+1820            parent_backdrop_image_tags: None,
+1821            album_id: None,
+1822            album_name: None,
+1823            album_artist: None,
+1824            artists: None,
+1825            artist_items: None,
+1826            index_number: None,
+1827            series_id: None,
+1828            series_name: None,
+1829            season_id: None,
+1830            season_name: None,
+1831            parent_index_number: None,
+1832            user_data: None,
+1833            media_streams: None,
+1834            media_sources: None,
+1835            people: None,
+1836        }
+1837    }
+1838
+1839    /// Helper to test the caching logic
+1840    struct TestHybridRepo {
+1841        offline: Arc<MockOfflineRepo>,
+1842        online: Arc<MockOnlineRepo>,
+1843    }
+1844
+1845    impl TestHybridRepo {
+1846        fn new(server_items: Vec<MediaItem>) -> Self {
+1847            let offline = Arc::new(MockOfflineRepo::new());
+1848            let online = Arc::new(MockOnlineRepo::new(server_items));
+1849            Self { offline, online }
+1850        }
+1851
+1852        /// Test version of get_items that implements the cache logic
+1853        async fn get_items(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
+1854            let offline = Arc::clone(&self.offline);
+1855            let offline_for_save = Arc::clone(&self.offline);
+1856            let online = Arc::clone(&self.online);
+1857            let parent_id = parent_id.to_string();
+1858            let parent_id_clone = parent_id.clone();
+1859            let parent_id_for_save = parent_id.clone();
+1860
+1861            // Check cache first
+1862            let cache_future = async move { offline.get_items(&parent_id, None).await };
+1863
+1864            let server_future = async move { online.get_items(&parent_id_clone, None).await };
+1865
+1866            // Wait for both, prefer cache if available
+1867            let (cache_result, server_result) = tokio::join!(cache_future, server_future);
+1868
+1869            // Check if cache had meaningful content
+1870            let cache_had_content = cache_result
+1871                .as_ref()
+1872                .map(|data| data.has_content())
+1873                .unwrap_or(false);
+1874
+1875            // Prefer cache if it has content (mimics hybrid.rs get_items logic)
+1876            let result = if cache_had_content {
+1877                cache_result?
+1878            } else {
+1879                // Use server result and save to cache for next time
+1880                let server_data = server_result?;
+1881
+1882                if !server_data.items.is_empty() {
+1883                    let items_clone = server_data.items.clone();
+1884                    offline_for_save
+1885                        .save_to_cache(&parent_id_for_save, &items_clone)
+1886                        .await?;
+1887                }
+1888
+1889                server_data
+1890            };
+1891
+1892            Ok(result)
+1893        }
+1894
+1895        /// Test version mirroring the real `HybridRepository::get_items`
+1896        /// downloads-only gate: when `include_catalog_browse()` is false, the
+1897        /// offline result is authoritative and the server is NOT queried, even
+1898        /// when the cache is empty. Otherwise falls through to the normal
+1899        /// cache-first logic in `get_items`.
+1900        async fn get_items_gated(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
+1901            if !crate::repository::offline::include_catalog_browse() {
+1902                let items = self.offline.get_items(parent_id, None).await?;
+1903                // Authoritative: return as-is, never touch the server.
+1904                return Ok(items);
+1905            }
+1906            self.get_items(parent_id).await
+1907        }
+1908    }
+1909
+1910    /// Serialize tests that mutate the process-global INCLUDE_CATALOG_BROWSE
+1911    /// flag, and always restore it to the default (true) afterwards.
+1912    static GATE_TEST_LOCK: Mutex<()> = Mutex::new(());
+1913
+1914    /// UT-070: with the downloads-only gate off, an empty offline result is
+1915    /// returned as-is and the server is NOT queried.
+1916    ///
+1917    /// @req-test: UR-052 - Offline "downloaded only" filtering
+1918    /// @req-test: DR-080 - Empty offline result is authoritative when gate off
+1919    #[tokio::test]
+1920    async fn test_get_items_gate_off_empty_does_not_query_server() {
+1921        let _guard = GATE_TEST_LOCK.lock_safe();
+1922        crate::repository::offline::set_include_catalog_browse(false);
+1923
+1924        // Server has items, cache is empty. Gate off ⇒ the server must be ignored.
+1925        let repo = TestHybridRepo::new(vec![
+1926            create_test_item("s-1", "Server 1"),
+1927            create_test_item("s-2", "Server 2"),
+1928        ]);
+1929
+1930        let result = repo.get_items_gated("parent-123").await.unwrap();
+1931
+1932        assert_eq!(
+1933            result.items.len(),
+1934            0,
+1935            "empty offline result is authoritative when the gate is off"
+1936        );
+1937        assert_eq!(
+1938            repo.online.get_query_count(),
+1939            0,
+1940            "server must NOT be queried when the gate is off"
+1941        );
+1942
+1943        crate::repository::offline::set_include_catalog_browse(true);
+1944    }
+1945
+1946    /// Guard the online path: with the gate ON and an empty cache, get_items
+1947    /// still falls through to the server (unchanged behaviour).
+1948    ///
+1949    /// @req-test: UR-052 - Offline "downloaded only" filtering
+1950    /// @req-test: DR-080 - Gate on ⇒ empty cache still queries the server
+1951    #[tokio::test]
+1952    async fn test_get_items_gate_on_empty_falls_through_to_server() {
+1953        let _guard = GATE_TEST_LOCK.lock_safe();
+1954        crate::repository::offline::set_include_catalog_browse(true);
+1955
+1956        let repo = TestHybridRepo::new(vec![
+1957            create_test_item("s-1", "Server 1"),
+1958            create_test_item("s-2", "Server 2"),
+1959        ]);
+1960
+1961        let result = repo.get_items_gated("parent-123").await.unwrap();
+1962
+1963        assert_eq!(result.items.len(), 2, "server result used on empty cache");
+1964        assert_eq!(
+1965            repo.online.get_query_count(),
+1966            1,
+1967            "server IS queried when the gate is on and the cache is empty"
+1968        );
+1969    }
+1970
+1971    /// Test cache miss saves server data to cache for next time
+1972    ///
+1973    /// @req-test: UR-002 - Access media when online or offline
+1974    /// @req-test: DR-013 - Repository pattern for online/offline data access
+1975    /// @req-test: DR-012 - Local database for media metadata cache
+1976    #[tokio::test]
+1977    async fn test_cache_miss_saves_to_cache() {
+1978        // Setup: Server has 3 items, cache is empty
+1979        let server_items = vec![
+1980            create_test_item("item-1", "Movie 1"),
+1981            create_test_item("item-2", "Movie 2"),
+1982            create_test_item("item-3", "Movie 3"),
+1983        ];
+1984
+1985        let repo = TestHybridRepo::new(server_items.clone());
+1986
+1987        // First request - cache miss
+1988        let result = repo.get_items("parent-123").await.unwrap();
+1989
+1990        // Should return server items
+1991        assert_eq!(result.items.len(), 3);
+1992        assert_eq!(result.items[0].id, "item-1");
+1993
+1994        // Should have queried both cache and server
+1995        assert_eq!(
+1996            repo.offline.get_query_count(),
+1997            1,
+1998            "Cache should be queried once"
+1999        );
+2000        assert_eq!(
+2001            repo.online.get_query_count(),
+2002            1,
+2003            "Server should be queried once"
+2004        );
+2005
+2006        // Should have saved to cache
+2007        assert_eq!(
+2008            repo.offline.get_save_count(),
+2009            1,
+2010            "Should save to cache on miss"
+2011        );
+2012    }
+2013
+2014    /// Test cache hit prevents duplicate save to cache
+2015    ///
+2016    /// Verifies parallel racing strategy: both cache and server are queried,
+2017    /// but when cache has content, it's used and no duplicate save occurs.
+2018    ///
+2019    /// @req-test: UR-002 - Access media when online or offline
+2020    /// @req-test: DR-013 - Repository pattern for online/offline data access
+2021    /// @req-test: DR-012 - Local database cache (avoid duplicate writes)
+2022    #[tokio::test]
+2023    async fn test_cache_hit_no_save() {
+2024        // Setup: Server has 3 items, we'll pre-populate cache
+2025        let server_items = vec![
+2026            create_test_item("item-1", "Movie 1"),
+2027            create_test_item("item-2", "Movie 2"),
+2028            create_test_item("item-3", "Movie 3"),
+2029        ];
+2030
+2031        let repo = TestHybridRepo::new(server_items.clone());
+2032
+2033        // Pre-populate cache
+2034        repo.offline
+2035            .save_to_cache("parent-123", &server_items)
+2036            .await
+2037            .unwrap();
+2038        assert_eq!(repo.offline.get_save_count(), 1);
+2039
+2040        // Second request - cache hit
+2041        let result = repo.get_items("parent-123").await.unwrap();
+2042
+2043        // Should return cached items
+2044        assert_eq!(result.items.len(), 3);
+2045        assert_eq!(result.items[0].id, "item-1");
+2046
+2047        // Should have queried cache and server (parallel race)
+2048        assert_eq!(repo.offline.get_query_count(), 1, "Cache should be queried");
+2049        assert_eq!(
+2050            repo.online.get_query_count(),
+2051            1,
+2052            "Server is queried in parallel"
+2053        );
+2054
+2055        // Should NOT have saved again (no duplicate save)
+2056        assert_eq!(
+2057            repo.offline.get_save_count(),
+2058            1,
+2059            "Should NOT save when using cache"
+2060        );
+2061    }
+2062
+2063    /// Test empty results are not saved to cache
+2064    ///
+2065    /// @req-test: DR-013 - Repository pattern (edge case handling)
+2066    /// @req-test: DR-012 - Local database cache (avoid saving empty data)
+2067    #[tokio::test]
+2068    async fn test_empty_cache_returns_empty_result() {
+2069        // Setup: Server has no items
+2070        let repo = TestHybridRepo::new(vec![]);
+2071
+2072        // Request with empty server
+2073        let result = repo.get_items("parent-123").await.unwrap();
+2074
+2075        // Should return empty result
+2076        assert_eq!(result.items.len(), 0);
+2077
+2078        // Should NOT save empty results
+2079        assert_eq!(
+2080            repo.offline.get_save_count(),
+2081            0,
+2082            "Should not save empty results"
+2083        );
+2084    }
+2085
+2086    /// Test SearchResult::has_content helper method
+2087    ///
+2088    /// @req-test: DR-013 - Repository pattern (content detection helper)
+2089    #[tokio::test]
+2090    async fn test_has_content_check() {
+2091        // Test that SearchResult::has_content works correctly
+2092        let empty_result = SearchResult {
+2093            items: vec![],
+2094            total_record_count: 0,
+2095        };
+2096        assert!(
+2097            !empty_result.has_content(),
+2098            "Empty result should not have content"
+2099        );
+2100
+2101        let result_with_items = SearchResult {
+2102            items: vec![create_test_item("item-1", "Movie 1")],
+2103            total_record_count: 1,
+2104        };
+2105        assert!(
+2106            result_with_items.has_content(),
+2107            "Result with items should have content"
+2108        );
+2109    }
+2110
+2111    #[test]
+2112    fn test_merge_search_local_first_then_server_appended() {
+2113        let cache = SearchResult {
+2114            items: vec![
+2115                create_test_item("a", "Cached A"),
+2116                create_test_item("b", "Cached B"),
+2117            ],
+2118            total_record_count: 2,
+2119        };
+2120        let server = SearchResult {
+2121            items: vec![
+2122                create_test_item("c", "Server C"),
+2123                create_test_item("d", "Server D"),
+2124            ],
+2125            total_record_count: 2,
+2126        };
+2127
+2128        let merged = HybridRepository::merge_search_results(cache, server);
+2129
+2130        // Local items first (in order), then server-only items appended.
+2131        let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
+2132        assert_eq!(ids, vec!["a", "b", "c", "d"]);
+2133        assert_eq!(merged.total_record_count, 4);
+2134    }
+2135
+2136    #[test]
+2137    fn test_merge_search_dedupes_with_server_winning() {
+2138        // "b" appears in both. Server metadata should win, but the item keeps
+2139        // its earlier (local) position and is not duplicated.
+2140        let cache = SearchResult {
+2141            items: vec![
+2142                create_test_item("a", "Cached A"),
+2143                create_test_item("b", "Cached B"),
+2144            ],
+2145            total_record_count: 2,
+2146        };
+2147        let server = SearchResult {
+2148            items: vec![
+2149                create_test_item("b", "Server B (fresher)"),
+2150                create_test_item("c", "Server C"),
+2151            ],
+2152            total_record_count: 2,
+2153        };
+2154
+2155        let merged = HybridRepository::merge_search_results(cache, server);
+2156
+2157        let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
+2158        assert_eq!(
+2159            ids,
+2160            vec!["a", "b", "c"],
+2161            "no duplicate, local position kept"
+2162        );
+2163
+2164        let b = merged.items.iter().find(|i| i.id == "b").unwrap();
+2165        assert_eq!(
+2166            b.name, "Server B (fresher)",
+2167            "server metadata wins on conflict"
+2168        );
+2169        assert_eq!(merged.total_record_count, 3);
+2170    }
+2171
+2172    #[test]
+2173    fn test_merge_search_handles_empty_sides() {
+2174        let only_server = HybridRepository::merge_search_results(
+2175            SearchResult {
+2176                items: vec![],
+2177                total_record_count: 0,
+2178            },
+2179            SearchResult {
+2180                items: vec![create_test_item("x", "X")],
+2181                total_record_count: 1,
+2182            },
+2183        );
+2184        assert_eq!(only_server.items.len(), 1);
+2185        assert_eq!(only_server.items[0].id, "x");
+2186
+2187        let only_cache = HybridRepository::merge_search_results(
+2188            SearchResult {
+2189                items: vec![create_test_item("y", "Y")],
+2190                total_record_count: 1,
+2191            },
+2192            SearchResult {
+2193                items: vec![],
+2194                total_record_count: 0,
+2195            },
+2196        );
+2197        assert_eq!(only_cache.items.len(), 1);
+2198        assert_eq!(only_cache.items[0].id, "y");
+2199    }
+2200}
+
\ 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..1a2445d4 --- /dev/null +++ b/api/src/jellytau_lib/repository/mod.rs.html @@ -0,0 +1,382 @@ +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;
+8/// Backend-owned stream selection (UR-079 / DR-225).
+9pub mod stream_selection;
+10pub mod types;
+11
+12pub use hybrid::HybridRepository;
+13pub use offline::OfflineRepository;
+14pub use online::{JRayActor, OnlineRepository};
+15pub use stream_selection::{StreamSelection, Transport};
+16pub use types::*;
+17
+18use async_trait::async_trait;
+19
+20/// Repository trait for media access (online, offline, or hybrid)
+21///
+22/// @req: UR-002 - Access media when online or offline
+23/// @req: UR-007 - Navigate media in library
+24/// @req: UR-008 - Search media across libraries
+25/// @req: IR-010 - Jellyfin API client for library browsing
+26/// @req: DR-012 - Local database for media metadata cache
+27/// @req: DR-013 - Repository pattern for online/offline data access
+28#[async_trait]
+29pub trait MediaRepository: Send + Sync {
+30    /// Get all libraries
+31    ///
+32    /// @req: UR-007 - Navigate media in library
+33    /// @req: JA-003 - Get user library views
+34    async fn get_libraries(&self) -> Result<Vec<Library>, RepoError>;
+35
+36    /// Get items in a library or parent
+37    ///
+38    /// @req: UR-007 - Navigate media in library
+39    /// @req: JA-004 - Get library items (paginated)
+40    async fn get_items(
+41        &self,
+42        parent_id: &str,
+43        options: Option<GetItemsOptions>,
+44    ) -> Result<SearchResult, RepoError>;
+45
+46    /// Get a single item by ID
+47    ///
+48    /// @req: UR-007 - Navigate media in library
+49    /// @req: JA-005 - Get item details and metadata
+50    async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError>;
+51
+52    /// Get latest items in a library
+53    ///
+54    /// @req: UR-024 - View recently added content on server
+55    /// @req: JA-016 - Get recently added items
+56    async fn get_latest_items(
+57        &self,
+58        parent_id: &str,
+59        limit: Option<usize>,
+60    ) -> Result<Vec<MediaItem>, RepoError>;
+61
+62    /// Get resume items (continue watching/listening)
+63    ///
+64    /// @req: UR-019 - Resume playback from where you left off
+65    /// @req: UR-023 - View "Next Up" / Continue Watching on home screen
+66    /// @req: JA-015 - Get "Continue Watching" items
+67    async fn get_resume_items(
+68        &self,
+69        parent_id: Option<&str>,
+70        limit: Option<usize>,
+71    ) -> Result<Vec<MediaItem>, RepoError>;
+72
+73    /// Get next up episodes
+74    ///
+75    /// @req: UR-023 - View "Next Up" / Continue Watching; auto-play next episode
+76    /// @req: JA-014 - Get "Next Up" items
+77    async fn get_next_up_episodes(
+78        &self,
+79        series_id: Option<&str>,
+80        limit: Option<usize>,
+81    ) -> Result<Vec<MediaItem>, RepoError>;
+82
+83    /// Get recently played audio
+84    async fn get_recently_played_audio(
+85        &self,
+86        limit: Option<usize>,
+87    ) -> Result<Vec<MediaItem>, RepoError>;
+88
+89    /// Get albums the user has played, but not recently ("rediscover" / haven't
+90    /// listened to in a while). Returns albums sorted by least-recently played
+91    /// first, optionally restricted to a parent library.
+92    async fn get_rediscover_albums(
+93        &self,
+94        parent_id: Option<&str>,
+95        limit: Option<usize>,
+96    ) -> Result<Vec<MediaItem>, RepoError>;
+97
+98    /// Get resume movies
+99    async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
+100
+101    /// Get genres
+102    async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError>;
+103
+104    /// Search for items
+105    ///
+106    /// @req: UR-008 - Search media across libraries
+107    /// @req: JA-006 - Search across libraries
+108    async fn search(
+109        &self,
+110        query: &str,
+111        options: Option<SearchOptions>,
+112    ) -> Result<SearchResult, RepoError>;
+113
+114    /// Get playback info for streaming
+115    ///
+116    /// @req: UR-003 - Play videos
+117    /// @req: UR-004 - Play audio uninterrupted
+118    /// @req: JA-007 - Get playback info and stream URL
+119    async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError>;
+120
+121    /// Get audio stream URL for a track
+122    ///
+123    /// @req: UR-004 - Play audio uninterrupted
+124    /// @req: JA-007 - Get playback info and stream URL
+125    async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
+126
+127    /// Get an audio-only stream URL for a *video* item (background-audio handoff).
+128    ///
+129    /// Used when autoplay advances to the next episode while the app is playing a
+130    /// video in audio-only mode in the background: the backend needs the next
+131    /// episode's audio-only URL without any frontend round-trip. Online-only;
+132    /// offline/cache repositories return an error.
+133    ///
+134    /// TRACES: UR-040 | JA-032
+135    async fn get_audio_only_stream_url_for_video(
+136        &self,
+137        item_id: &str,
+138        media_source_id: Option<&str>,
+139        start_time_seconds: Option<f64>,
+140        audio_stream_index: Option<i32>,
+141    ) -> Result<String, RepoError>;
+142
+143    /// Get Live TV channels (broadcast / IPTV) for browsing.
+144    async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
+145
+146    /// Get the root list of plugin "Channels" (Jellyfin Channels feature).
+147    /// Drill-down into a channel reuses `get_items(channel_id, ...)`.
+148    async fn get_channels(&self) -> Result<SearchResult, RepoError>;
+149
+150    /// Open a live stream (Live TV channel or live channel item) for playback.
+151    ///
+152    /// Returns the server transcoding URL plus identifiers needed to manage the
+153    /// stream. Required before a live channel can be played over HLS.
+154    async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError>;
+155
+156    /// Report playback start
+157    ///
+158    /// @req: UR-025 - Sync watch history and progress back to Jellyfin
+159    /// @req: JA-010 - Report playback start
+160    async fn report_playback_start(
+161        &self,
+162        item_id: &str,
+163        position_ticks: i64,
+164    ) -> Result<(), RepoError>;
+165
+166    /// Report playback progress
+167    ///
+168    /// @req: UR-025 - Sync watch history and progress back to Jellyfin
+169    /// @req: JA-011 - Report playback progress (periodic)
+170    async fn report_playback_progress(
+171        &self,
+172        item_id: &str,
+173        position_ticks: i64,
+174    ) -> Result<(), RepoError>;
+175
+176    /// Report playback stopped
+177    ///
+178    /// @req: UR-025 - Sync watch history and progress back to Jellyfin
+179    /// @req: JA-012 - Report playback stopped
+180    async fn report_playback_stopped(
+181        &self,
+182        item_id: &str,
+183        position_ticks: i64,
+184    ) -> Result<(), RepoError>;
+185
+186    /// Get image URL (synchronous - just constructs URL)
+187    fn get_image_url(
+188        &self,
+189        item_id: &str,
+190        image_type: ImageType,
+191        options: Option<ImageOptions>,
+192    ) -> String;
+193
+194    /// Get subtitle URL (synchronous - just constructs URL)
+195    /// Called by frontend via Tauri invoke (getSubtitleUrl in VideoPlayer.svelte)
+196    #[allow(dead_code)]
+197    fn get_subtitle_url(
+198        &self,
+199        item_id: &str,
+200        media_source_id: &str,
+201        stream_index: i32,
+202        format: &str,
+203    ) -> String;
+204
+205    /// Build the URL a video download is fetched from. Synchronous — it only
+206    /// constructs a URL, so it stays testable without a server. Reach it through
+207    /// [`resolve_video_download_url`] rather than calling it directly.
+208    ///
+209    /// `source_audio_codec` is the codec of the audio track the server would
+210    /// serve (see [`served_audio_codec`]); `None` when it is not known. At
+211    /// `original` quality it decides whether the file can be copied byte-for-byte
+212    /// or has to have its audio re-encoded on the way down — a downloaded file is
+213    /// played back with no server in reach, so it has to be decodable *here*.
+214    ///
+215    /// TRACES: UR-071 | DR-171
+216    #[allow(dead_code)]
+217    fn get_video_download_url(
+218        &self,
+219        item_id: &str,
+220        quality: &str,
+221        media_source_id: Option<&str>,
+222        source_audio_codec: Option<&str>,
+223    ) -> String;
+224
+225    /// Mark item as favorite
+226    async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
+227
+228    /// Unmark item as favorite
+229    async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
+230
+231    /// Everything the viewer has favourited, across every library.
+232    ///
+233    /// Separate from `get_items` because favourites span libraries and
+234    /// `get_items` is `ParentId`-shaped. `scope` is the opaque enum the
+235    /// frontend sends; this layer expands it to item types (DR-063) so no
+236    /// Jellyfin taxonomy is needed on the other side of the IPC boundary.
+237    ///
+238    /// TRACES: UR-067 | DR-115, JA-033 | UT-100, UT-101
+239    async fn get_favorites(
+240        &self,
+241        scope: SearchScope,
+242        options: Option<GetItemsOptions>,
+243    ) -> Result<SearchResult, RepoError>;
+244
+245    /// Erase the viewer's watch history for an item: clear its played flag and
+246    /// its resume position. On a container (series, season) this applies to
+247    /// everything inside it, so a series is returned to "never watched" and
+248    /// reopens on its premiere.
+249    ///
+250    /// TRACES: UR-064 | DR-106
+251    async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError>;
+252
+253    /// Mark an item played — the inverse of `clear_watch_history`. Needed by the
+254    /// sync-queue drain, which replays `mark_played` rows queued while the
+255    /// server was unreachable; reporting a stop at a made-up position was the
+256    /// previous stand-in and does not set the played flag reliably.
+257    ///
+258    /// TRACES: UR-025 | DR-131 | JA-035
+259    async fn mark_played(&self, item_id: &str) -> Result<(), RepoError>;
+260
+261    /// Get person details
+262    async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
+263
+264    /// Get items by person (filmography)
+265    async fn get_items_by_person(
+266        &self,
+267        person_id: &str,
+268        options: Option<GetItemsOptions>,
+269    ) -> Result<SearchResult, RepoError>;
+270
+271    /// Get similar/related items for a movie or show
+272    ///
+273    /// @req: UR-009 - Discover similar content based on current item
+274    async fn get_similar_items(
+275        &self,
+276        item_id: &str,
+277        limit: Option<usize>,
+278    ) -> Result<SearchResult, RepoError>;
+279
+280    // ===== Playlist Methods =====
+281
+282    /// Create a new playlist on the server
+283    ///
+284    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+285    /// @req: JA-019 - Get/create/update playlists
+286    async fn create_playlist(
+287        &self,
+288        name: &str,
+289        item_ids: &[String],
+290    ) -> Result<PlaylistCreatedResult, RepoError>;
+291
+292    /// Delete a playlist
+293    ///
+294    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+295    /// @req: JA-019 - Get/create/update playlists
+296    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
+297
+298    /// Rename a playlist
+299    ///
+300    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+301    /// @req: JA-019 - Get/create/update playlists
+302    async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
+303
+304    /// Get playlist items with PlaylistItemId (needed for remove/reorder)
+305    ///
+306    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+307    /// @req: JA-019 - Get/create/update playlists
+308    async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
+309
+310    /// Add items to a playlist
+311    ///
+312    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+313    /// @req: JA-020 - Add/remove items from playlist
+314    async fn add_to_playlist(
+315        &self,
+316        playlist_id: &str,
+317        item_ids: &[String],
+318    ) -> Result<(), RepoError>;
+319
+320    /// Remove items from a playlist using entry IDs (PlaylistItemId, NOT media item IDs)
+321    ///
+322    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+323    /// @req: JA-020 - Add/remove items from playlist
+324    async fn remove_from_playlist(
+325        &self,
+326        playlist_id: &str,
+327        entry_ids: &[String],
+328    ) -> Result<(), RepoError>;
+329
+330    /// Move a playlist item to a new position
+331    ///
+332    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+333    /// @req: JA-020 - Add/remove items from playlist
+334    async fn move_playlist_item(
+335        &self,
+336        playlist_id: &str,
+337        item_id: &str,
+338        new_index: u32,
+339    ) -> Result<(), RepoError>;
+340}
+341
+342/// The audio codec the server would serve for `item_id` — the default track, or
+343/// the first when none is marked, matching the track Jellyfin picks.
+344///
+345/// `None` when the item has no audio, names no codec, or cannot be fetched. A
+346/// caller must read that as "unknown", never as "fine": it is the input to a
+347/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
+348/// exactly as it was.
+349///
+350/// TRACES: UR-071 | DR-171 | UT-166
+351pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
+352    let item = repo.get_item(item_id).await.ok()?;
+353    let audio: Vec<(Option<&str>, bool)> = item
+354        .media_streams
+355        .as_deref()
+356        .unwrap_or_default()
+357        .iter()
+358        .filter(|s| s.stream_type == "Audio")
+359        .map(|s| (s.codec.as_deref(), s.is_default))
+360        .collect();
+361
+362    device_profile::served_audio_codec(&audio).map(str::to_string)
+363}
+364
+365/// Resolve the download URL for a video, applying the audio-codec policy that
+366/// keeps the saved file playable offline (DR-171).
+367///
+368/// Every video download goes through here rather than calling the builder
+369/// directly: the builder is pure and cannot look the codec up, and a caller that
+370/// forgets to is exactly how the silent downloads shipped.
+371///
+372/// TRACES: UR-071 | DR-171
+373pub async fn resolve_video_download_url(
+374    repo: &dyn MediaRepository,
+375    item_id: &str,
+376    quality: &str,
+377    media_source_id: Option<&str>,
+378) -> String {
+379    let codec = served_audio_codec(repo, item_id).await;
+380    repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
+381}
+
\ 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..e5513a13 --- /dev/null +++ b/api/src/jellytau_lib/repository/online.rs.html @@ -0,0 +1,4426 @@ +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::stream_selection::{
+9    quality_options_for_source, PlaybackKind, Rendition, StreamSelection, Transport,
+10};
+11use super::{types::*, MediaRepository};
+12use crate::connectivity::ConnectivityReporter;
+13use crate::jellyfin::HttpClient;
+14use crate::settings::StreamingQuality;
+15use crate::utils::lock::RwLockSafe;
+16
+17/// The viewer's **durable** bandwidth ceiling — the device default.
+18///
+19/// Process-wide rather than a field on [`OnlineRepository`] because it is a user
+20/// preference about *this device's connection*, not about a server session: it
+21/// must survive a repository being rebuilt on re-login, and every URL builder and
+22/// the `PlaybackInfo` negotiation have to agree on it or the cap leaks (the
+23/// negotiation would authorise a direct play the URL builder then never gets to
+24/// constrain). Same shape as `offline::INCLUDE_CATALOG_BROWSE`.
+25///
+26/// Set from `player_set_video_settings` (Settings), and restored from the
+27/// database at startup. **Not** set by the in-player picker — see
+28/// [`PLAYBACK_QUALITY_OVERRIDE`].
+29///
+30/// TRACES: UR-074 | DR-162
+31static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQuality::Original);
+32
+33/// The ceiling in force for *the playback happening right now*, when the viewer
+34/// has moved this one item off the device default.
+35///
+36/// This exists because a single global cannot express "this 4K remux needs a
+37/// ceiling, that podcast does not". The in-player picker is a "this film, this
+38/// connection" control — its own doc has said so since it was written — but it
+39/// was implemented by writing the device default, so choosing 2 Mbps to get one
+40/// awkward film moving silently capped every video played afterwards, for the
+41/// rest of the process, with the Settings screen still displaying the old value.
+42///
+43/// Cleared when a new item starts playing, so the override cannot outlive the
+44/// playback it was chosen for. The device default is never touched by it.
+45///
+46/// TRACES: UR-074, UR-079 | DR-226
+47static PLAYBACK_QUALITY_OVERRIDE: RwLock<Option<StreamingQuality>> = RwLock::new(None);
+48
+49/// Set the durable device default. Applies to every stream opened afterwards
+50/// that has no per-playback override.
+51///
+52/// Streams already playing keep the bitrate they were opened at — a cap is a
+53/// property of the URL the server is transcoding for, so changing it mid-stream
+54/// requires re-opening at the new quality (`player_set_stream_quality`).
+55///
+56/// TRACES: UR-074 | DR-162
+57pub fn set_streaming_quality(quality: StreamingQuality) {
+58    *STREAMING_QUALITY.write_safe() = quality;
+59}
+60
+61/// The durable device default, ignoring any per-playback override.
+62///
+63/// Read this only where the *setting* is the subject — the Settings screen, and
+64/// persistence. Everything that opens a stream wants
+65/// [`effective_streaming_quality`] instead.
+66///
+67/// TRACES: UR-074 | DR-162
+68pub fn streaming_quality() -> StreamingQuality {
+69    *STREAMING_QUALITY.read_safe()
+70}
+71
+72/// Move *this playback* to a different ceiling without disturbing the default.
+73///
+74/// TRACES: UR-074, UR-079 | DR-226
+75pub fn set_playback_quality_override(quality: StreamingQuality) {
+76    *PLAYBACK_QUALITY_OVERRIDE.write_safe() = Some(quality);
+77}
+78
+79/// Drop any per-playback override, returning to the device default.
+80///
+81/// Called when playback moves to a new item: a ceiling chosen for one film must
+82/// not silently govern the next one. Autoplaying the next episode is the case
+83/// that matters — nobody re-opens the picker between episodes.
+84///
+85/// TRACES: UR-074, UR-079 | DR-226
+86pub fn clear_playback_quality_override() {
+87    *PLAYBACK_QUALITY_OVERRIDE.write_safe() = None;
+88}
+89
+90/// The per-playback override, if one is in force.
+91///
+92/// TRACES: UR-074, UR-079 | DR-226
+93pub fn playback_quality_override() -> Option<StreamingQuality> {
+94    *PLAYBACK_QUALITY_OVERRIDE.read_safe()
+95}
+96
+97/// The ceiling that actually applies to a stream opened now: the per-playback
+98/// override if the viewer set one, otherwise the device default.
+99///
+100/// This is the single resolution point. Every URL builder and the `PlaybackInfo`
+101/// negotiation must go through it, for the same reason they all had to agree on
+102/// the old static: a negotiation that authorises a direct play the URL builder
+103/// then constrains (or vice versa) leaks the cap.
+104///
+105/// TRACES: UR-074, UR-079 | DR-226
+106pub fn effective_streaming_quality() -> StreamingQuality {
+107    playback_quality_override().unwrap_or_else(streaming_quality)
+108}
+109
+110/// Every request this app makes identifies the same device, so the device id
+111/// alone cannot tell two transcodes of the same item apart — see
+112/// [`begin_video_play_session`].
+113const DEVICE_ID: &str = "jellytau-tauri";
+114
+115/// The `PlaySessionId` of the video transcode most recently opened, so the next
+116/// open can stop it.
+117///
+118/// Process-wide for the same reason as [`STREAMING_QUALITY`]: it describes what
+119/// *this device* currently has running on the server, and must survive the
+120/// repository being rebuilt on re-login.
+121///
+122/// TRACES: UR-074 | DR-162
+123static VIDEO_PLAY_SESSION: RwLock<Option<String>> = RwLock::new(None);
+124
+125/// Claim a transcode identity for a stream about to be opened, returning the new
+126/// `PlaySessionId` and the one it replaces (if any).
+127///
+128/// Jellyfin keys a transcode job by device *and* play session. Without a session
+129/// id every open of the same item on this device looked like the same job, so
+130/// re-opening a stream — a quality switch, a transcoded seek, an audio-track
+131/// switch — left the old ffmpeg running and the server intermittently rejected
+132/// segment requests for the new one (`400` on `hls1/main/0.ts`) while the two
+133/// fought over one transcode path. The caller stops the returned previous
+134/// session before the new stream's segments are fetched.
+135///
+136/// TRACES: UR-074 | DR-177 | UT-173
+137pub fn begin_video_play_session() -> (String, Option<String>) {
+138    let new_session = uuid::Uuid::new_v4().to_string();
+139    let mut current = VIDEO_PLAY_SESSION.write_safe();
+140    let previous = current.replace(new_session.clone());
+141    (new_session, previous)
+142}
+143
+144/// Take ownership of a transcode this process did not build a URL for, returning
+145/// the session it replaces.
+146///
+147/// When `PlaybackInfo` answers with a `TranscodingUrl` the server has already
+148/// started the job and named the session; that id is the only handle on it we
+149/// will ever have. Without adopting it, the first re-open of that stream has no
+150/// previous session to stop and collides with the very job that was playing.
+151///
+152/// TRACES: UR-074 | DR-177 | UT-173
+153pub fn adopt_video_play_session(session_id: String) -> Option<String> {
+154    VIDEO_PLAY_SESSION.write_safe().replace(session_id)
+155}
+156
+157/// A single actor returned by the JRay plugin's "context at time t" endpoint.
+158///
+159/// Mirrors the `actors[]` objects from `GET /Plugins/JRay/Items/{id}/jray?t=`.
+160/// `jellyfin_id` (a Jellyfin Person item GUID) is preferred for navigation;
+161/// the IMDb/TMDb ids are informational fallbacks. Unknown ids are `""`.
+162#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+163pub struct JRayActor {
+164    pub name: String,
+165    #[serde(default)]
+166    pub imdb_id: String,
+167    #[serde(default)]
+168    pub tmdb_id: String,
+169    #[serde(default)]
+170    pub jellyfin_id: String,
+171}
+172
+173/// Envelope returned by the JRay `jray?t=` endpoint. Extra keys (future
+174/// `locations`, `trivia`, …) are ignored so the client tolerates schema growth.
+175#[derive(Debug, Clone, Deserialize)]
+176struct JRayContext {
+177    #[serde(default)]
+178    actors: Vec<JRayActor>,
+179}
+180
+181/// Online repository - fetches data from Jellyfin server via HTTP
+182pub struct OnlineRepository {
+183    http_client: Arc<HttpClient>,
+184    server_url: String,
+185    user_id: String,
+186    access_token: String,
+187    /// Reports the outcome of every server request to the connectivity monitor.
+188    /// This is the source of truth for the offline/online banner. `None` in
+189    /// tests / contexts where connectivity tracking isn't wired up.
+190    connectivity: Option<ConnectivityReporter>,
+191}
+192
+193impl OnlineRepository {
+194    /// The signed-in user these requests are made as. Needed by the favourites
+195    /// drain, which reads this user's queued rows. TRACES: UR-069 | DR-120
+196    pub fn user_id(&self) -> &str {
+197        &self.user_id
+198    }
+199
+200    pub fn new(
+201        http_client: Arc<HttpClient>,
+202        server_url: String,
+203        user_id: String,
+204        access_token: String,
+205    ) -> Self {
+206        Self {
+207            http_client,
+208            server_url,
+209            user_id,
+210            access_token,
+211            connectivity: None,
+212        }
+213    }
+214
+215    /// Attach a connectivity reporter so server outcomes drive the reachability
+216    /// state observed by the UI. See `report_outcome`.
+217    pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
+218        self.connectivity = Some(reporter);
+219        self
+220    }
+221
+222    /// Feed a request outcome into the connectivity monitor.
+223    ///
+224    /// Classification (matches docs/architecture/07-connectivity.md):
+225    /// - `Ok` / `Authentication` / `NotFound` / `Server` → the server answered,
+226    ///   so it is reachable → `report_success` (instant recovery).
+227    /// - `Network` → connection-level failure → `report_network_failure`
+228    ///   (subject to the time-window debounce before going offline).
+229    /// - `Database` → not a server signal → ignored.
+230    async fn report_outcome<T>(&self, result: &Result<T, RepoError>) {
+231        let Some(reporter) = &self.connectivity else {
+232            return;
+233        };
+234
+235        match result {
+236            Ok(_)
+237            | Err(RepoError::Authentication { .. })
+238            | Err(RepoError::NotFound { .. })
+239            | Err(RepoError::Server { .. }) => {
+240                reporter.report_success().await;
+241            }
+242            Err(RepoError::Network { message }) => {
+243                reporter.report_network_failure(Some(message.clone())).await;
+244            }
+245            Err(RepoError::Database { .. }) | Err(RepoError::Offline) => {
+246                // Local-side errors (cache failure / already-offline) — not a
+247                // statement about the server's reachability, so ignore them.
+248            }
+249        }
+250    }
+251
+252    /// Build authorization header
+253    fn auth_header(&self) -> String {
+254        HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
+255    }
+256
+257    /// Download raw bytes from a URL using the shared authenticated HTTP client.
+258    /// Used by thumbnail cache to download images with proper auth and connection reuse.
+259    pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
+260        let request = self
+261            .http_client
+262            .client
+263            .get(url)
+264            .header("X-Emby-Authorization", self.auth_header())
+265            .build()
+266            .map_err(|e| format!("Failed to build request: {}", e))?;
+267
+268        let response = self
+269            .http_client
+270            .request_with_retry(request)
+271            .await
+272            .map_err(|e| format!("Download failed: {}", e))?;
+273
+274        if !response.status().is_success() {
+275            let status = response.status();
+276            let body = response.text().await.unwrap_or_default();
+277            let body_preview = if body.len() > 200 {
+278                &body[..200]
+279            } else {
+280                &body
+281            };
+282            return Err(format!("HTTP {} ({})", status, body_preview.trim()));
+283        }
+284
+285        response
+286            .bytes()
+287            .await
+288            .map(|b| b.to_vec())
+289            .map_err(|e| format!("Failed to read bytes: {}", e))
+290    }
+291
+292    /// Query the JRay plugin for the actors on screen at time `t` (seconds) in
+293    /// the given item. Returns an empty list when the plugin isn't installed or
+294    /// has no truth data for the item (HTTP 404), so callers can treat "no JRay"
+295    /// and "nobody on screen" identically. Other failures propagate.
+296    pub async fn get_jray_actors(
+297        &self,
+298        item_id: &str,
+299        t: f64,
+300    ) -> Result<Vec<JRayActor>, RepoError> {
+301        let endpoint = format!(
+302            "/Plugins/JRay/Items/{}/jray?t={}",
+303            urlencoding::encode(item_id),
+304            t
+305        );
+306        match self.get_json::<JRayContext>(&endpoint).await {
+307            Ok(context) => Ok(context.actors),
+308            // No plugin / no truth data for this item — not an error to the user.
+309            Err(RepoError::NotFound { .. }) => Ok(Vec::new()),
+310            Err(e) => Err(e),
+311        }
+312    }
+313
+314    /// Make authenticated GET request
+315    async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
+316        // Fast-fail when connectivity is known-offline. Without this every request
+317        // still runs the full HTTP retry/backoff cycle (~7s) before giving up,
+318        // which stalls cache-miss paths and makes offline browsing feel janky.
+319        // The offline recovery probe (connectivity monitor) flips us back to
+320        // reachable the moment the server returns, so this never sticks.
+321        if let Some(reporter) = &self.connectivity {
+322            if !reporter.is_reachable().await {
+323                return Err(RepoError::Offline);
+324            }
+325        }
+326
+327        let result = self.get_json_inner(endpoint).await;
+328        self.report_outcome(&result).await;
+329        result
+330    }
+331
+332    async fn get_json_inner<T: for<'de> Deserialize<'de>>(
+333        &self,
+334        endpoint: &str,
+335    ) -> Result<T, RepoError> {
+336        let url = format!("{}{}", self.server_url, endpoint);
+337
+338        let request = self
+339            .http_client
+340            .client
+341            .get(&url)
+342            .header("X-Emby-Authorization", self.auth_header())
+343            .build()
+344            .map_err(|e| RepoError::Network {
+345                message: format!("Failed to build request: {}", e),
+346            })?;
+347
+348        let response = self
+349            .http_client
+350            .request_with_retry(request)
+351            .await
+352            .map_err(|e| RepoError::Network {
+353                message: e.to_string(),
+354            })?;
+355
+356        if !response.status().is_success() {
+357            let status = response.status();
+358            if status.as_u16() == 401 || status.as_u16() == 403 {
+359                return Err(RepoError::Authentication {
+360                    message: format!("HTTP {}", status),
+361                });
+362            } else if status.as_u16() == 404 {
+363                return Err(RepoError::NotFound {
+364                    message: "Resource not found".to_string(),
+365                });
+366            } else {
+367                return Err(RepoError::Server {
+368                    message: format!("HTTP {}", status),
+369                });
+370            }
+371        }
+372
+373        // Get the response text first for better error reporting
+374        let text = response.text().await.map_err(|e| RepoError::Server {
+375            message: format!("Failed to read response: {}", e),
+376        })?;
+377
+378        // Try to deserialize and log the raw JSON on error
+379        serde_json::from_str(&text).map_err(|e| {
+380            error!(
+381                "[OnlineRepo] Failed to deserialize {} response: {}",
+382                endpoint, e
+383            );
+384            error!(
+385                "[OnlineRepo] Response body (first 1000 chars): {}",
+386                if text.len() > 1000 {
+387                    &text[..1000]
+388                } else {
+389                    &text
+390                }
+391            );
+392            RepoError::Server {
+393                message: format!("Failed to parse response: {}", e),
+394            }
+395        })
+396    }
+397
+398    /// Make authenticated POST request
+399    async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
+400        let result = self.post_json_inner(endpoint, body).await;
+401        self.report_outcome(&result).await;
+402        result
+403    }
+404
+405    async fn post_json_inner<T: Serialize>(
+406        &self,
+407        endpoint: &str,
+408        body: &T,
+409    ) -> Result<(), RepoError> {
+410        let url = format!("{}{}", self.server_url, endpoint);
+411
+412        let request = self
+413            .http_client
+414            .client
+415            .post(&url)
+416            .header("Content-Type", "application/json")
+417            .header("X-Emby-Authorization", self.auth_header())
+418            .json(body)
+419            .build()
+420            .map_err(|e| RepoError::Network {
+421                message: format!("Failed to build request: {}", e),
+422            })?;
+423
+424        let response = self
+425            .http_client
+426            .request_with_retry(request)
+427            .await
+428            .map_err(|e| RepoError::Network {
+429                message: e.to_string(),
+430            })?;
+431
+432        if !response.status().is_success() {
+433            let status = response.status();
+434            if status.as_u16() == 401 || status.as_u16() == 403 {
+435                return Err(RepoError::Authentication {
+436                    message: format!("HTTP {}", status),
+437                });
+438            } else {
+439                return Err(RepoError::Server {
+440                    message: format!("HTTP {}", status),
+441                });
+442            }
+443        }
+444
+445        Ok(())
+446    }
+447
+448    /// Make authenticated POST request and return response
+449    async fn post_json_response<T: Serialize, R: for<'de> Deserialize<'de>>(
+450        &self,
+451        endpoint: &str,
+452        body: &T,
+453    ) -> Result<R, RepoError> {
+454        let result = self.post_json_response_inner(endpoint, body).await;
+455        self.report_outcome(&result).await;
+456        result
+457    }
+458
+459    async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
+460        &self,
+461        endpoint: &str,
+462        body: &T,
+463    ) -> Result<R, RepoError> {
+464        let url = format!("{}{}", self.server_url, endpoint);
+465
+466        // Log request body for debugging
+467        if let Ok(json) = serde_json::to_string_pretty(body) {
+468            debug!("[HTTP] POST {}", endpoint);
+469            debug!("[HTTP] Request body:\n{}", json);
+470        }
+471
+472        let request = self
+473            .http_client
+474            .client
+475            .post(&url)
+476            .header("Content-Type", "application/json")
+477            .header("X-Emby-Authorization", self.auth_header())
+478            .json(body)
+479            .build()
+480            .map_err(|e| RepoError::Network {
+481                message: format!("Failed to build request: {}", e),
+482            })?;
+483
+484        let response = self
+485            .http_client
+486            .request_with_retry(request)
+487            .await
+488            .map_err(|e| RepoError::Network {
+489                message: e.to_string(),
+490            })?;
+491
+492        if !response.status().is_success() {
+493            let status = response.status();
+494
+495            // Capture response body for error details
+496            let error_body = response
+497                .text()
+498                .await
+499                .unwrap_or_else(|_| "Failed to read error body".to_string());
+500            error!("[HTTP] Error response ({}): {}", status, error_body);
+501
+502            if status.as_u16() == 401 || status.as_u16() == 403 {
+503                return Err(RepoError::Authentication {
+504                    message: format!("HTTP {}: {}", status, error_body),
+505                });
+506            } else if status.as_u16() == 404 {
+507                return Err(RepoError::NotFound {
+508                    message: format!("Resource not found: {}", error_body),
+509                });
+510            } else {
+511                return Err(RepoError::Server {
+512                    message: format!("HTTP {}: {}", status, error_body),
+513                });
+514            }
+515        }
+516
+517        response.json().await.map_err(|e| RepoError::Server {
+518            message: format!("Failed to parse response: {}", e),
+519        })
+520    }
+521
+522    /// Ask the server to tear down a transcode this device started.
+523    ///
+524    /// Best-effort and deliberately un-retried: it runs on the path that opens a
+525    /// replacement stream, so a slow or failed stop must not delay playback. The
+526    /// worst case if it does fail is the job Jellyfin would have reaped on its
+527    /// own idle timer anyway — the new stream still has its own session id, so it
+528    /// no longer collides with the old one.
+529    ///
+530    /// TRACES: UR-074 | DR-177
+531    async fn stop_transcode(&self, play_session_id: &str) {
+532        let url = format!(
+533            "{}/Videos/ActiveEncodings?deviceId={}&playSessionId={}",
+534            self.server_url, DEVICE_ID, play_session_id
+535        );
+536
+537        let request = self
+538            .http_client
+539            .client
+540            .delete(&url)
+541            .header("X-Emby-Authorization", self.auth_header())
+542            .send();
+543
+544        match request.await {
+545            Ok(response) if response.status().is_success() => {
+546                debug!("[Transcode] Stopped previous encoding {}", play_session_id);
+547            }
+548            Ok(response) => {
+549                debug!(
+550                    "[Transcode] Server declined to stop encoding {}: HTTP {}",
+551                    play_session_id,
+552                    response.status()
+553                );
+554            }
+555            Err(e) => {
+556                debug!(
+557                    "[Transcode] Could not stop encoding {}: {}",
+558                    play_session_id, e
+559                );
+560            }
+561        }
+562    }
+563
+564    /// Get a video stream URL (initial play, resume, transcoded seeking,
+565    /// audio-track switching).
+566    ///
+567    /// Returns an HLS master playlist (`/Videos/{id}/master.m3u8`) transcoded to
+568    /// h264/aac. HLS is used rather than a progressive `stream.mp4` because the
+569    /// HTML5 `<video>` element (via HLS.js) starts playing within seconds and can
+570    /// seek within the stream, whereas a progressive MP4 transcode of HEVC source
+571    /// forces the server to transcode the whole file before playback can begin —
+572    /// which manifests as playback never starting.
+573    ///
+574    /// **There is deliberately no start-position parameter.** A playlist covers
+575    /// the whole item and asking for segment N *is* the seek, so a position would
+576    /// be redundant — and actively fatal: Jellyfin builds every segment URI by
+577    /// echoing this playlist's query string into it, while its segment handler
+578    /// rejects `StartTimeTicks > 0` outright (`ArgumentException` → `400`). One
+579    /// resume position here therefore 400s every segment of the stream, which
+580    /// presents as a resumed episode that simply never plays while the same
+581    /// episode from the beginning is fine. Resume by seeking the player once it
+582    /// has loaded. (The progressive `/Audio/universal` builder below has no
+583    /// segments and keeps its `StartTimeTicks`.)
+584    ///
+585    /// The stream is built against the current [`effective_streaming_quality`]
+586    /// ceiling (the per-playback override if one is set, else the device default):
+587    /// `MaxStreamingBitrate`/`VideoBitrate`/`AudioBitrate`, plus a `MaxHeight`
+588    /// that suits the budget. `Original` keeps the historical 20/18 Mbps
+589    /// allowance, which is a transcode ceiling rather than a user-facing limit.
+590    ///
+591    /// TRACES: UR-004, UR-074 | DR-140, DR-162, DR-177, DR-181 | UT-130, UT-156, UT-173, UT-182
+592    pub async fn get_video_stream_url(
+593        &self,
+594        item_id: &str,
+595        media_source_id: Option<&str>,
+596        audio_stream_index: Option<i32>,
+597    ) -> Result<String, RepoError> {
+598        let quality = effective_streaming_quality();
+599        // `Original` is uncapped as a *user* setting, but a transcode still needs
+600        // a ceiling to encode against — keep the values this endpoint has always
+601        // used so nothing changes for the default.
+602        let max_bitrate = quality.max_bitrate().unwrap_or(20_000_000);
+603        let video_bitrate = quality.video_bitrate().unwrap_or(18_000_000);
+604
+605        // Claim a distinct transcode identity and retire the one it replaces, so
+606        // the server is never running two jobs for this device at once. Doing it
+607        // here covers every path that re-opens a stream (quality switch,
+608        // transcoded seek, audio-track switch) rather than each remembering to.
+609        let (play_session_id, superseded) = begin_video_play_session();
+610        if let Some(previous) = superseded {
+611            self.stop_transcode(&previous).await;
+612        }
+613
+614        // Build an HLS transcode URL, naming every codec this renderer can
+615        // decode rather than only h264.
+616        //
+617        // The list is what lets the server *copy* the video stream instead of
+618        // re-encoding it. Hardcoding h264 meant an hevc source whose only
+619        // problem was its audio — eac3 on a device with no Dolby licence — got
+620        // its picture fully re-encoded to satisfy a sound problem. The server's
+621        // own transcoding URL already did the right thing (`h264,hevc`, video
+622        // copied, `TranscodeReasons=AudioCodecNotSupported`); this builder,
+623        // which takes over whenever a quality change or track switch re-opens
+624        // the stream, quietly did not — so changing quality turned a cheap
+625        // remux into a full transcode.
+626        //
+627        // On the webview path this still resolves to "h264" alone, so nothing
+628        // changes there.
+629        //
+630        // TRACES: UR-004, UR-080 | DR-234
+631        let (renderer_video_codecs, _) = super::device_profile::renderer_codecs();
+632        let mut params = vec![
+633            ("api_key", self.access_token.clone()),
+634            ("DeviceId", DEVICE_ID.to_string()),
+635            ("PlaySessionId", play_session_id),
+636            ("VideoCodec", renderer_video_codecs),
+637            ("AudioCodec", "aac".to_string()),
+638            ("MaxStreamingBitrate", max_bitrate.to_string()),
+639            ("VideoBitrate", video_bitrate.to_string()),
+640            ("AudioBitrate", quality.audio_bitrate().to_string()),
+641            (
+642                "TranscodingMaxAudioChannels",
+643                super::device_profile::max_audio_channels().to_string(),
+644            ),
+645            ("SegmentContainer", "ts".to_string()),
+646            ("TranscodingContainer", "ts".to_string()),
+647            ("TranscodingProtocol", "hls".to_string()),
+648            // Say "no subtitle" rather than leaving the choice open. An omitted
+649            // index is not neutral: the server then picks the source's own
+650            // default/forced track, and an image-based one can only be delivered
+651            // by burning it into the picture (DR-176). The negotiation already
+652            // sends this sentinel, but most streams are opened by rebuilding
+653            // *this* URL — a quality switch, a transcoded seek, an audio-track
+654            // switch — so it has to hold here too, independently of whatever
+655            // session state the server still holds.
+656            (
+657                "SubtitleStreamIndex",
+658                super::device_profile::playback_subtitle_stream_index().to_string(),
+659            ),
+660        ];
+661
+662        // Scale the picture down to what the budget can carry. Omitted for the
+663        // uncapped steps so the source resolution is preserved.
+664        if let Some(height) = quality.max_height() {
+665            params.push(("MaxHeight", height.to_string()));
+666        }
+667
+668        // Only pin an audio track when the user actually picked one. Jellyfin's
+669        // `MediaStream.Index` is global across *all* streams in a media source, so
+670        // index 0 is the video stream on virtually every file — defaulting to 0
+671        // asks the server to transcode the video stream as the audio track, which
+672        // yields a picture with no sound. Omitting the param lets the server use
+673        // the source's `DefaultAudioStreamIndex`.
+674        if let Some(index) = audio_stream_index {
+675            params.push(("AudioStreamIndex", index.to_string()));
+676        }
+677
+678        if let Some(source_id) = media_source_id {
+679            params.push(("MediaSourceId", source_id.to_string()));
+680        }
+681
+682        // Build query string (values are already safe, no encoding needed)
+683        let query = params
+684            .iter()
+685            .map(|(k, v)| format!("{}={}", k, v))
+686            .collect::<Vec<_>>()
+687            .join("&");
+688
+689        let url = format!(
+690            "{}/Videos/{}/master.m3u8?{}",
+691            self.server_url, item_id, query
+692        );
+693
+694        Ok(url)
+695    }
+696
+697    /// Get an **audio-only** stream URL for a *video* item, for the
+698    /// background-audio handoff (UR-040).
+699    ///
+700    /// TRACES: UR-040 | JA-032, DR-140 | UT-059, UT-130
+701    ///
+702    /// This deliberately targets `/Audio/{id}/universal`, NOT the video stream:
+703    /// the server extracts/transcodes only the item's audio track and streams
+704    /// pure audio bytes — no video frames reach the device, so there is no client
+705    /// video decode while backgrounded. Do NOT "optimize" this to reuse the
+706    /// `/Videos/.../master.m3u8` URL: that would keep the device decoding video,
+707    /// defeating the entire point of the feature.
+708    ///
+709    /// `AudioStreamIndex` carries the user's currently-selected audio track over
+710    /// from the video player; `StartTimeTicks` resumes at the handoff position.
+711    /// `universal` lets the server pick direct-play vs transcode per codec/device.
+712    ///
+713    /// The stream is a **progressive** container (mp3 over plain HTTP), NOT HLS:
+714    /// ExoPlayer plays this natively, whereas an HLS/`ts` transcode on the
+715    /// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
+716    /// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
+717    /// decodable and supports mid-stream `StartTimeTicks`.
+718    pub async fn build_audio_only_stream_url_for_video(
+719        &self,
+720        item_id: &str,
+721        media_source_id: Option<&str>,
+722        start_time_seconds: Option<f64>,
+723        audio_stream_index: Option<i32>,
+724    ) -> Result<String, RepoError> {
+725        let mut params = vec![
+726            ("UserId", self.user_id.clone()),
+727            ("api_key", self.access_token.clone()),
+728            ("DeviceId", DEVICE_ID.to_string()),
+729            // Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
+730            ("Container", "mp3".to_string()),
+731            ("AudioCodec", "mp3".to_string()),
+732            ("TranscodingContainer", "mp3".to_string()),
+733            ("TranscodingProtocol", "http".to_string()),
+734            // Audio-only is already far under any video cap, but a user on the
+735            // bottom rungs of the ladder asked for *less traffic*, so take the
+736            // lower of the two rather than always 384 kbps.
+737            // TRACES: UR-074 | DR-162
+738            (
+739                "MaxStreamingBitrate",
+740                effective_streaming_quality()
+741                    .audio_bitrate()
+742                    .min(384_000)
+743                    .to_string(),
+744            ),
+745        ];
+746
+747        // Carry the track over only if one was actually selected — index 0 is the
+748        // video stream, not "the first audio track" (see `get_video_stream_url`).
+749        if let Some(index) = audio_stream_index {
+750            params.push(("AudioStreamIndex", index.to_string()));
+751        }
+752
+753        if let Some(source_id) = media_source_id {
+754            params.push(("MediaSourceId", source_id.to_string()));
+755        }
+756
+757        if let Some(seconds) = start_time_seconds {
+758            let ticks = (seconds * 10_000_000.0) as i64;
+759            params.push(("StartTimeTicks", ticks.to_string()));
+760        }
+761
+762        let query = params
+763            .iter()
+764            .map(|(k, v)| format!("{}={}", k, v))
+765            .collect::<Vec<_>>()
+766            .join("&");
+767
+768        let url = format!("{}/Audio/{}/universal?{}", self.server_url, item_id, query);
+769
+770        Ok(url)
+771    }
+772
+773    /// Ask the server what it will do with this item, under our device profile
+774    /// and the ceiling currently in force.
+775    ///
+776    /// The single place the device profile is built and POSTed. Both
+777    /// [`get_playback_info`](Self::get_playback_info) (the legacy shape) and
+778    /// `get_stream_selection` (the DR-225 contract) go through it, so the
+779    /// profile they negotiate under cannot drift apart.
+780    ///
+781    /// TRACES: UR-004, UR-074, UR-079 | DR-225, DR-228
+782    async fn negotiate_playback(
+783        &self,
+784        item_id: &str,
+785    ) -> Result<(NegotiatedSource, String), RepoError> {
+786        let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
+787
+788        // What the renderer that will decode this can play. One source, shared
+789        // with the transcode URL builder and the client-side audio override, so
+790        // the profile we advertise and the stream we then ask for cannot
+791        // disagree. TRACES: UR-004, UR-080 | DR-234
+792        let (video_codecs, audio_codecs) = super::device_profile::renderer_codecs();
+793
+794        // Video plays in a webview <video> element on every platform, which
+795        // decodes a narrower audio set than the platform does — so the video
+796        // profile must claim less than the audio-only profile. Without this a
+797        // Dolby-licensed device advertises eac3, gets a direct play, and shows
+798        // picture with no sound.
+799        let video_audio_codecs = super::device_profile::video_audio_codecs(&audio_codecs);
+800
+801        info!("[DeviceProfile] Using video codecs: {}", video_codecs);
+802        info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
+803        info!(
+804            "[DeviceProfile] Audio codecs for video direct play: {}",
+805            video_audio_codecs
+806        );
+807
+808        // Bound every profile by what the audio route can actually voice, so a
+809        // multichannel track is downmixed by the server rather than direct-played
+810        // into a sink that has nowhere to put the extra channels.
+811        let max_audio_channels = super::device_profile::max_audio_channels().to_string();
+812        info!("[DeviceProfile] Max audio channels: {}", max_audio_channels);
+813
+814        // The user's bandwidth ceiling has to be part of the *negotiation*, not
+815        // just the transcode URL: `max_static_bitrate` is what makes the server
+816        // refuse to direct-play a source fatter than the cap, and without it a
+817        // 30 Mbps remux is handed over untouched and every URL parameter
+818        // downstream is moot. `Original` keeps the historical "no ceiling"
+819        // sentinel so the default path negotiates exactly as before.
+820        //
+821        // TRACES: UR-074 | DR-162
+822        let quality = effective_streaming_quality();
+823        let negotiated_bitrate = quality.max_bitrate().unwrap_or(999_999_999) as i64;
+824        if let Some(cap) = quality.max_bitrate() {
+825            info!(
+826                "[DeviceProfile] Streaming quality cap active: {} ({} bps)",
+827                quality.label(),
+828                cap
+829            );
+830        }
+831
+832        // Create device profile with detected hardware capabilities
+833        let device_profile = DeviceProfile {
+834            name: "JellyTau Native Player".to_string(),
+835            max_streaming_bitrate: negotiated_bitrate,
+836            max_static_bitrate: negotiated_bitrate,
+837            max_audio_channels: max_audio_channels.clone(),
+838            direct_play_profiles: vec![
+839                DirectPlayProfile {
+840                    profile_type: "Video".to_string(),
+841                    container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp".to_string(),
+842                    video_codec: Some(video_codecs.clone()),
+843                    // The webview decodes this stream, not ExoPlayer/MPV.
+844                    audio_codec: video_audio_codecs.clone(),
+845                },
+846                DirectPlayProfile {
+847                    profile_type: "Audio".to_string(),
+848                    container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
+849                    video_codec: None,
+850                    // Audio-only really is the native player's, so it keeps the
+851                    // full platform list — narrowing it would transcode music
+852                    // that plays perfectly well.
+853                    audio_codec: audio_codecs.clone(),
+854                },
+855            ],
+856            transcoding_profiles: vec![
+857                TranscodingProfile {
+858                    profile_type: "Video".to_string(),
+859                    context: "Streaming".to_string(),
+860                    protocol: "hls".to_string(),
+861                    container: "ts".to_string(),
+862                    // The server may only transcode *to* something this renderer
+863                    // can decode. This said "h264,hevc" unconditionally while the
+864                    // direct-play profile claims h264 alone on the webview path —
+865                    // a straight contradiction: it tells the server "I cannot
+866                    // play hevc, so re-encode it" and then "re-encoding it to
+867                    // hevc is fine". When the server took that option the webview
+868                    // got a stream it could not decode, which presents as video
+869                    // stuck on its first frame rather than as an error.
+870                    //
+871                    // Capped at the two codecs a Jellyfin server actually
+872                    // encodes, so a wider decode list never asks it for an av1
+873                    // encode. TRACES: UR-004, UR-080 | DR-234
+874                    video_codec: Some(
+875                        if video_codecs.contains("hevc") {
+876                            "h264,hevc"
+877                        } else {
+878                            "h264"
+879                        }
+880                        .to_string(),
+881                    ),
+882                    audio_codec: "aac,mp3".to_string(),
+883                    max_audio_channels: max_audio_channels.clone(),
+884                },
+885                TranscodingProfile {
+886                    profile_type: "Audio".to_string(),
+887                    context: "Streaming".to_string(),
+888                    protocol: "http".to_string(),
+889                    container: "mp3".to_string(),
+890                    video_codec: None,
+891                    audio_codec: "mp3".to_string(),
+892                    max_audio_channels: max_audio_channels.clone(),
+893                },
+894            ],
+895            subtitle_profiles: super::device_profile::subtitle_profiles()
+896                .into_iter()
+897                .map(|(format, method)| SubtitleProfile {
+898                    format: format.to_string(),
+899                    method: method.to_string(),
+900                })
+901                .collect(),
+902        };
+903
+904        // POST to PlaybackInfo with device profile containing detected codecs
+905        let request_body = PlaybackInfoRequest {
+906            user_id: self.user_id.clone(),
+907            audio_stream_index: None, // Let the server pick the source default
+908            // Never let the server choose a subtitle track for us. Omitting this
+909            // makes it honour the source's default/forced flag, and an image-based
+910            // default (PGS) it cannot send as a sidecar becomes SubtitleMethod=Encode
+911            // — burn-in, which forces a full video re-encode of a stream that would
+912            // otherwise be remuxed. The app renders subtitles itself (UR-020).
+913            //
+914            // TRACES: UR-020, UR-004 | DR-176 | UT-168
+915            subtitle_stream_index: Some(super::device_profile::playback_subtitle_stream_index()),
+916            start_time_ticks: 0,
+917            is_playback: true,
+918            auto_open_live_stream: true,
+919            // The user's cap, or the historical 20 Mbps allowance when uncapped.
+920            max_streaming_bitrate: quality.max_bitrate().unwrap_or(20_000_000) as i64,
+921            device_profile: Some(device_profile), // Now sending profile with detected codecs
+922        };
+923
+924        let response: PlaybackInfoResponse =
+925            self.post_json_response(&endpoint, &request_body).await?;
+926        let source = response
+927            .media_sources
+928            .into_iter()
+929            .next()
+930            .ok_or(RepoError::NotFound {
+931                message: "No media sources available".to_string(),
+932            })?;
+933
+934        Ok((source, response.play_session_id))
+935    }
+936
+937    /// Decide what stream to play, and describe it fully enough that no consumer
+938    /// has to guess.
+939    ///
+940    /// This replaces `get_video_stream_url`'s "always build an HLS transcode"
+941    /// with an actual decision. Measured against the development server, that
+942    /// distinction is worth 85% of plays on Android (ExoPlayer decodes hevc, and
+943    /// the sampled library is ~80% hevc) and about 7% on Linux, where the
+944    /// WebKitGTK profile can only claim h264 until the native-video work lands.
+945    /// The Linux figure is a property of the renderer, not of this code.
+946    ///
+947    /// The returned [`StreamSelection`] carries the transport explicitly so the
+948    /// frontend stops testing the URL for `.m3u8`, and the quality ladder for
+949    /// *this* source so the picker stops offering rungs that mean nothing for it.
+950    ///
+951    /// TRACES: UR-070, UR-079 | DR-225, DR-226, DR-227, DR-228 | UT-213
+952    pub async fn get_stream_selection(
+953        &self,
+954        item_id: &str,
+955        media_source_id: Option<&str>,
+956        audio_stream_index: Option<i32>,
+957    ) -> Result<StreamSelection, RepoError> {
+958        let (source, play_session_id) = self.negotiate_playback(item_id).await?;
+959
+960        let quality = effective_streaming_quality();
+961        let source_bitrate = source.bitrate.and_then(|b| u64::try_from(b).ok());
+962        let available = quality_options_for_source(source_bitrate);
+963
+964        // Judge the audio track we would actually be served against what this
+965        // platform's video renderer can decode, and override the server's
+966        // answer. Jellyfin 10.11.5 honours a DirectPlayProfile's container and
+967        // video codec but ignores its audio codec, so it will offer direct play
+968        // for an E-AC-3 track and the webview renders the picture in silence.
+969        let audio_streams: Vec<(Option<&str>, bool)> = source
+970            .media_streams
+971            .iter()
+972            .filter(|stream| stream.stream_type == "Audio")
+973            .map(|stream| (stream.codec.as_deref(), stream.is_default))
+974            .collect();
+975        let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
+976
+977        // A pinned audio track is a request the *source file* cannot satisfy: a
+978        // direct play serves the file's own default track, so honouring the
+979        // choice means having the server produce a stream built around it.
+980        let track_pinned = audio_stream_index.is_some();
+981
+982        let effective_source_id = media_source_id.unwrap_or(&source.id).to_string();
+983
+984        let decided = decide_playback_kind(&source, audio_forces_transcode, track_pinned);
+985
+986        let selection = match decided {
+987            PlaybackKind::Transcode => {
+988                let url = if let Some(transcoding_url) = source
+989                    .transcoding_url
+990                    .as_deref()
+991                    .filter(|_| !track_pinned && audio_stream_index.is_none())
+992                {
+993                    // The server started this job and named the session — adopt
+994                    // it, or a later quality switch / seek has no previous job to
+995                    // stop and contends with the one currently playing.
+996                    if let Some(previous) = adopt_video_play_session(play_session_id.clone()) {
+997                        self.stop_transcode(&previous).await;
+998                    }
+999                    // The server built this URL from its *own* subtitle verdict,
+1000                    // so it can hand back the burn-in the request declined.
+1001                    // TRACES: UR-020, UR-004 | DR-176
+1002                    format!(
+1003                        "{}{}",
+1004                        self.server_url,
+1005                        super::device_profile::without_server_chosen_subtitle(transcoding_url)
+1006                    )
+1007                } else {
+1008                    // Build our own transcode URL: either the server offered
+1009                    // none, or the viewer pinned an audio track the server's URL
+1010                    // does not carry.
+1011                    self.get_video_stream_url(
+1012                        item_id,
+1013                        Some(&effective_source_id),
+1014                        audio_stream_index,
+1015                    )
+1016                    .await?
+1017                };
+1018
+1019                StreamSelection {
+1020                    url,
+1021                    // Every transcode this app requests is HLS — a progressive
+1022                    // transcode of an HEVC source makes the server encode the
+1023                    // whole file before playback starts, which presents as
+1024                    // playback never beginning (DR-140).
+1025                    transport: Transport::Hls,
+1026                    playback_kind: PlaybackKind::Transcode,
+1027                    rendition: Some(Rendition {
+1028                        quality,
+1029                        max_bitrate: quality.max_bitrate(),
+1030                        max_height: quality.max_height(),
+1031                        video_codec: Some("h264".to_string()),
+1032                        audio_codec: Some("aac".to_string()),
+1033                    }),
+1034                    available,
+1035                    media_source_id: Some(effective_source_id),
+1036                    play_session_id: Some(play_session_id),
+1037                    needs_transcoding: PlaybackKind::Transcode.needs_transcoding(),
+1038                }
+1039            }
+1040            kind @ (PlaybackKind::DirectPlay | PlaybackKind::DirectStream) => {
+1041                // The original bytes, or a remux of them. No `AudioStreamIndex`:
+1042                // `static=true` serves the file untouched, and pinning index 0
+1043                // (which is the *video* stream — the index is global across all
+1044                // streams) only misleads servers that do honour it.
+1045                let url = format!(
+1046                    "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId={}&api_key={}&userId={}",
+1047                    self.server_url,
+1048                    item_id,
+1049                    effective_source_id,
+1050                    DEVICE_ID,
+1051                    self.access_token,
+1052                    self.user_id
+1053                );
+1054
+1055                StreamSelection {
+1056                    url,
+1057                    // A static file over HTTP, seekable by byte range — not a
+1058                    // playlist. This is the case the `.m3u8` sniff got wrong in
+1059                    // the safe direction only by accident.
+1060                    transport: Transport::Progressive,
+1061                    playback_kind: kind,
+1062                    // Direct play *is* the source; there is no chosen rendition
+1063                    // to report, and reporting the ceiling that happens to be set
+1064                    // would misdescribe what the viewer is receiving.
+1065                    rendition: None,
+1066                    available,
+1067                    media_source_id: Some(effective_source_id),
+1068                    play_session_id: Some(play_session_id),
+1069                    needs_transcoding: kind.needs_transcoding(),
+1070                }
+1071            }
+1072        };
+1073
+1074        info!(
+1075            "[StreamSelection] {} → {:?} over {:?} (source bitrate {:?}, ceiling {})",
+1076            item_id,
+1077            selection.playback_kind,
+1078            selection.transport,
+1079            source_bitrate,
+1080            quality.label(),
+1081        );
+1082
+1083        Ok(selection)
+1084    }
+1085}
+1086
+1087// Jellyfin API response types (PascalCase from server)
+1088#[derive(Debug, Deserialize)]
+1089#[serde(rename_all = "PascalCase")]
+1090struct ItemsResponse {
+1091    items: Vec<JellyfinItem>,
+1092    total_record_count: usize,
+1093}
+1094
+1095/// Jellyfin playlist creation response
+1096#[derive(Debug, Deserialize)]
+1097#[serde(rename_all = "PascalCase")]
+1098struct CreatePlaylistResponse {
+1099    id: String,
+1100}
+1101
+1102/// Jellyfin playlist items response — items include PlaylistItemId
+1103#[derive(Debug, Deserialize)]
+1104#[serde(rename_all = "PascalCase")]
+1105#[allow(dead_code)]
+1106struct PlaylistItemsResponse {
+1107    items: Vec<JellyfinPlaylistItem>,
+1108    total_record_count: usize,
+1109}
+1110
+1111/// A playlist item from Jellyfin — wraps a regular item with an entry-scoped ID
+1112#[derive(Debug, Deserialize)]
+1113#[serde(rename_all = "PascalCase")]
+1114struct JellyfinPlaylistItem {
+1115    playlist_item_id: String,
+1116    #[serde(flatten)]
+1117    item: JellyfinItem,
+1118}
+1119
+1120#[derive(Debug, Deserialize)]
+1121#[serde(rename_all = "PascalCase")]
+1122struct JellyfinItem {
+1123    id: String,
+1124    name: String,
+1125    #[serde(rename = "Type")]
+1126    item_type: String,
+1127    #[serde(default)]
+1128    is_folder: bool,
+1129    parent_id: Option<String>,
+1130    overview: Option<String>,
+1131    genres: Option<Vec<String>>,
+1132    production_year: Option<i32>,
+1133    premiere_date: Option<String>,
+1134    community_rating: Option<f64>,
+1135    official_rating: Option<String>,
+1136    run_time_ticks: Option<i64>,
+1137    image_tags: Option<ImageTags>,
+1138    backdrop_image_tags: Option<Vec<String>>,
+1139    parent_backdrop_image_tags: Option<Vec<String>>,
+1140    album_id: Option<String>,
+1141    album: Option<String>,
+1142    album_artist: Option<String>,
+1143    artists: Option<Vec<String>>,
+1144    artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
+1145    index_number: Option<i32>,
+1146    parent_index_number: Option<i32>,
+1147    series_id: Option<String>,
+1148    series_name: Option<String>,
+1149    season_id: Option<String>,
+1150    season_name: Option<String>,
+1151    media_streams: Option<Vec<JellyfinMediaStream>>,
+1152    media_sources: Option<Vec<JellyfinMediaSource>>,
+1153    people: Option<Vec<crate::repository::types::Person>>,
+1154    user_data: Option<JellyfinUserData>,
+1155}
+1156
+1157/// Per-user state Jellyfin attaches to an item (favourite, played, resume).
+1158///
+1159/// Returned on every `/Users/{uid}/Items*` response; we additionally name
+1160/// `UserData` in the `Fields=` list so the shape is explicit rather than
+1161/// dependent on the server's default field set.
+1162///
+1163/// `PlaybackPositionTicks` is the server's resume position for the item, and the
+1164/// only place it is published — Jellyfin has no per-item "resume position"
+1165/// endpoint, so reading `UserData` *is* how a resume point is obtained.
+1166///
+1167/// TRACES: UR-019, UR-069 | DR-113, JA-013, JA-034 | UT-099
+1168#[derive(Debug, Deserialize, Clone)]
+1169#[serde(rename_all = "PascalCase")]
+1170struct JellyfinUserData {
+1171    playback_position_ticks: Option<i64>,
+1172    #[serde(rename = "Played")]
+1173    is_played: Option<bool>,
+1174    is_favorite: Option<bool>,
+1175    play_count: Option<i32>,
+1176    last_played_date: Option<String>,
+1177}
+1178
+1179impl From<JellyfinUserData> for UserData {
+1180    fn from(jf: JellyfinUserData) -> Self {
+1181        UserData {
+1182            playback_position_ticks: jf.playback_position_ticks,
+1183            playback_position_ms: jf.playback_position_ticks.map(crate::domain::ticks_to_ms),
+1184            is_played: jf.is_played,
+1185            is_favorite: jf.is_favorite,
+1186            play_count: jf.play_count,
+1187            last_played_date: jf.last_played_date,
+1188            playback_context_type: None,
+1189            playback_context_id: None,
+1190        }
+1191    }
+1192}
+1193
+1194/// Build the Jellyfin endpoint for a folder listing.
+1195///
+1196/// Extracted from `get_items` so the query it produces — in particular the
+1197/// favourites filter — can be asserted without standing up an HTTP server.
+1198///
+1199/// TRACES: UR-007, UR-067 | DR-116 | UT-104
+1200fn build_get_items_endpoint(
+1201    user_id: &str,
+1202    parent_id: &str,
+1203    options: Option<&GetItemsOptions>,
+1204) -> String {
+1205    // Every value below is percent-encoded before it goes into the query
+1206    // string, the same way `Genres` and `SearchTerm` already are: these are
+1207    // values, not URL syntax, so a space or an `&` in one must not split it
+1208    // into another parameter.
+1209    //
+1210    // TRACES: UR-007 | DR-212 | UT-206
+1211    let mut endpoint = format!(
+1212        "/Users/{}/Items?ParentId={}",
+1213        user_id,
+1214        urlencoding::encode(parent_id)
+1215    );
+1216
+1217    if let Some(opts) = options {
+1218        if let Some(limit) = opts.limit {
+1219            endpoint.push_str(&format!("&Limit={}", limit));
+1220        }
+1221        if let Some(start_index) = opts.start_index {
+1222            endpoint.push_str(&format!("&StartIndex={}", start_index));
+1223        }
+1224        if let Some(types) = &opts.include_item_types {
+1225            // Encode each type, not the joined string: the comma is the
+1226            // list separator Jellyfin splits on.
+1227            let encoded: Vec<String> = types
+1228                .iter()
+1229                .map(|t| urlencoding::encode(t).into_owned())
+1230                .collect();
+1231            endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
+1232        }
+1233        if let Some(sort_by) = &opts.sort_by {
+1234            // SortBy is likewise a comma-delimited list (`hybrid.rs` sends
+1235            // "ParentIndexNumber,IndexNumber,SortName"), so encode per field.
+1236            let encoded: Vec<String> = sort_by
+1237                .split(',')
+1238                .map(|field| urlencoding::encode(field).into_owned())
+1239                .collect();
+1240            endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
+1241        }
+1242        if let Some(sort_order) = &opts.sort_order {
+1243            endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
+1244        }
+1245        if let Some(recursive) = opts.recursive {
+1246            endpoint.push_str(&format!("&Recursive={}", recursive));
+1247        }
+1248        if let Some(genres) = &opts.genres {
+1249            if !genres.is_empty() {
+1250                // Genre names may contain spaces/ampersands, so percent-encode each.
+1251                let encoded: Vec<String> = genres
+1252                    .iter()
+1253                    .map(|g| urlencoding::encode(g).into_owned())
+1254                    .collect();
+1255                endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
+1256            }
+1257        }
+1258        // TRACES: UR-067 | DR-116 | UT-104
+1259        if opts.favorites_only == Some(true) {
+1260            endpoint.push_str("&Filters=IsFavorite");
+1261        }
+1262    }
+1263
+1264    // Request image fields for list views (People only needed in get_item
+1265    // detail view). Genres is needed so cached items carry their genres,
+1266    // which lets the offline store derive genre lists + per-genre counts.
+1267    endpoint
+1268        .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
+1269    endpoint
+1270}
+1271
+1272/// Build the Jellyfin endpoint for a "recently added" listing.
+1273///
+1274/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
+1275/// `false`, which returns each newly-added *leaf* separately, so importing one
+1276/// 14-track album pushed 14 rows into "recently added" and buried everything
+1277/// else. With grouping on, the server collapses children into the container
+1278/// that was added — an album appears once, while movies (which have no such
+1279/// container) are unaffected.
+1280///
+1281/// Pulled out of `get_latest_items` so the query can be asserted without an
+1282/// HTTP server, matching `build_favorites_endpoint`.
+1283///
+1284/// TRACES: UR-024, UR-034 | IR-024, JA-016
+1285fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
+1286    format!(
+1287        "/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+1288        user_id,
+1289        parent_id,
+1290        limit.unwrap_or(16)
+1291    )
+1292}
+1293
+1294/// Build the Jellyfin endpoint for a Next Up listing.
+1295///
+1296/// `EnableResumable=false` is the point of this query: the server default is
+1297/// `true`, which makes a partially-watched episode its own series' "next up" —
+1298/// the very episode `/Items/Resume` returns — so Continue Watching and Next Up
+1299/// end up showing the same cards. Next Up should only ever offer episodes the
+1300/// viewer has not started. Servers predating the parameter ignore it, which is
+1301/// why the frontend also drops in-progress entries (DR-197).
+1302///
+1303/// Pulled out of `get_next_up_episodes` so the query can be asserted without an
+1304/// HTTP server, matching `build_favorites_endpoint`.
+1305///
+1306/// TRACES: UR-023, UR-059 | DR-197, JA-014, JA-036 | UT-190, UT-191
+1307fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
+1308    let mut endpoint = format!(
+1309        "/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+1310        user_id,
+1311        limit.unwrap_or(16)
+1312    );
+1313
+1314    if let Some(sid) = series_id {
+1315        endpoint.push_str(&format!("&SeriesId={}", sid));
+1316    }
+1317
+1318    endpoint
+1319}
+1320
+1321/// Build the Jellyfin endpoint for a favourites listing.
+1322///
+1323/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
+1324/// server. `scope` is expanded here — `SearchScope::All` yields `None`, and the
+1325/// `IncludeItemTypes` filter is then **omitted entirely** rather than sent as a
+1326/// union, which would silently drop every type nobody enumerated (see
+1327/// `SearchScope::item_types`).
+1328///
+1329/// TRACES: UR-067 | DR-115, JA-033 | UT-100
+1330fn build_favorites_endpoint(
+1331    user_id: &str,
+1332    scope: SearchScope,
+1333    options: Option<&GetItemsOptions>,
+1334) -> String {
+1335    let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);
+1336
+1337    if let Some(types) = scope.item_types() {
+1338        endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
+1339    }
+1340
+1341    // Jellyfin has no "date favourited", so name order is the only stable sort
+1342    // available; callers may still override it.
+1343    let sort_by = options
+1344        .and_then(|o| o.sort_by.as_deref())
+1345        .unwrap_or("SortName");
+1346    let sort_order = options
+1347        .and_then(|o| o.sort_order.as_deref())
+1348        .unwrap_or("Ascending");
+1349    endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));
+1350
+1351    if let Some(limit) = options.and_then(|o| o.limit) {
+1352        endpoint.push_str(&format!("&Limit={}", limit));
+1353    }
+1354    if let Some(start_index) = options.and_then(|o| o.start_index) {
+1355        endpoint.push_str(&format!("&StartIndex={}", start_index));
+1356    }
+1357
+1358    endpoint
+1359        .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
+1360    endpoint
+1361}
+1362
+1363// ImageTags from Jellyfin API - can be a HashMap with various image type keys
+1364// We use a wrapper to extract just the Primary tag we need
+1365#[derive(Debug, Deserialize)]
+1366#[serde(untagged)]
+1367enum ImageTags {
+1368    // Modern format: HashMap
+1369    Map(std::collections::HashMap<String, String>),
+1370    // Legacy/alternative format: structured
+1371    Structured {
+1372        #[serde(rename = "Primary")]
+1373        primary: Option<String>,
+1374    },
+1375}
+1376
+1377impl ImageTags {
+1378    fn primary(&self) -> Option<String> {
+1379        match self {
+1380            ImageTags::Map(map) => map.get("Primary").cloned(),
+1381            ImageTags::Structured { primary } => primary.clone(),
+1382        }
+1383    }
+1384}
+1385
+1386#[derive(Debug, Deserialize, Clone)]
+1387#[serde(rename_all = "PascalCase")]
+1388struct JellyfinMediaStream {
+1389    #[serde(rename = "Type")]
+1390    stream_type: String,
+1391    codec: Option<String>,
+1392    language: Option<String>,
+1393    display_title: Option<String>,
+1394    index: i32,
+1395    is_default: bool,
+1396    #[serde(default)]
+1397    is_forced: bool,
+1398}
+1399
+1400#[derive(Debug, Deserialize, Clone)]
+1401#[serde(rename_all = "PascalCase")]
+1402struct JellyfinMediaSource {
+1403    id: String,
+1404    name: String,
+1405    container: Option<String>,
+1406    size: Option<i64>,
+1407    bitrate: Option<i32>,
+1408    supports_direct_play: bool,
+1409    supports_direct_stream: bool,
+1410    supports_transcoding: bool,
+1411    direct_stream_url: Option<String>,
+1412}
+1413
+1414impl JellyfinItem {
+1415    fn into_media_item(self, server_id: String) -> MediaItem {
+1416        // Extract image tags before consuming self
+1417        let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
+1418        let backdrop_tags = self.backdrop_image_tags;
+1419
+1420        let kind = crate::domain::kind_from_jellyfin(&self.item_type, self.is_folder);
+1421
+1422        MediaItem {
+1423            id: self.id,
+1424            name: self.name,
+1425            item_type: self.item_type,
+1426            kind,
+1427            is_folder: self.is_folder,
+1428            server_id,
+1429            parent_id: self.parent_id,
+1430            library_id: None, // Not provided by Jellyfin API directly
+1431            overview: self.overview,
+1432            genres: self.genres,
+1433            production_year: self.production_year,
+1434            premiere_date: self.premiere_date,
+1435            community_rating: self.community_rating,
+1436            official_rating: self.official_rating,
+1437            runtime_ticks: self.run_time_ticks,
+1438            duration_ms: self.run_time_ticks.map(crate::domain::ticks_to_ms),
+1439            primary_image_tag: primary_tag.clone(),
+1440            image_id: primary_tag,
+1441            backdrop_image_tags: backdrop_tags,
+1442            parent_backdrop_image_tags: self.parent_backdrop_image_tags,
+1443            album_id: self.album_id,
+1444            album_name: self.album,
+1445            album_artist: self.album_artist,
+1446            artists: self.artists,
+1447            artist_items: self.artist_items,
+1448            index_number: self.index_number,
+1449            parent_index_number: self.parent_index_number,
+1450            series_id: self.series_id,
+1451            series_name: self.series_name,
+1452            season_id: self.season_id,
+1453            season_name: self.season_name,
+1454            // Favourite/played/resume state as the server sees it. TRACES:
+1455            // UR-069 | DR-113, JA-034
+1456            user_data: self.user_data.map(UserData::from),
+1457            media_streams: self.media_streams.map(|streams| {
+1458                streams
+1459                    .into_iter()
+1460                    .map(|s| {
+1461                        let kind = crate::domain::stream_kind_from_jellyfin(&s.stream_type);
+1462                        // Only a subtitle can be a sidecar; asked of anything
+1463                        // else the question has no answer. TRACES: UR-020 |
+1464                        // DR-176 | UT-168
+1465                        let supports_external_delivery =
+1466                            (kind == crate::domain::StreamKind::Subtitle).then(|| {
+1467                                super::device_profile::subtitle_supports_external_delivery(
+1468                                    s.codec.as_deref(),
+1469                                )
+1470                            });
+1471                        crate::repository::types::MediaStream {
+1472                            kind,
+1473                            stream_type: s.stream_type,
+1474                            codec: s.codec,
+1475                            language: s.language,
+1476                            display_title: s.display_title,
+1477                            index: s.index,
+1478                            is_default: s.is_default,
+1479                            is_forced: s.is_forced,
+1480                            supports_external_delivery,
+1481                        }
+1482                    })
+1483                    .collect()
+1484            }),
+1485            media_sources: self.media_sources.map(|sources| {
+1486                sources
+1487                    .into_iter()
+1488                    .map(|s| crate::repository::types::MediaSource {
+1489                        id: s.id,
+1490                        name: s.name,
+1491                        container: s.container,
+1492                        size: s.size,
+1493                        bitrate: s.bitrate,
+1494                        supports_direct_play: s.supports_direct_play,
+1495                        supports_direct_stream: s.supports_direct_stream,
+1496                        supports_transcoding: s.supports_transcoding,
+1497                        direct_stream_url: s.direct_stream_url,
+1498                    })
+1499                    .collect()
+1500            }),
+1501            people: self.people,
+1502        }
+1503    }
+1504}
+1505
+1506// ---------------------------------------------------------------------------
+1507// PlaybackInfo negotiation
+1508//
+1509// These types were local to `get_playback_info`. They are module-scope now
+1510// because `get_stream_selection` negotiates with the same request and has to
+1511// read the same answer — restating the device profile in a second place is
+1512// exactly how the cap used to leak (a negotiation authorising a direct play the
+1513// URL builder then never got to constrain).
+1514//
+1515// TRACES: UR-079 | DR-225, DR-228
+1516// ---------------------------------------------------------------------------
+1517
+1518#[derive(Debug, Serialize)]
+1519#[serde(rename_all = "PascalCase")]
+1520struct PlaybackInfoRequest {
+1521    user_id: String,
+1522    /// Omitted so the server resolves the source's default audio stream.
+1523    /// Never send 0 here: the index is global across all streams, so 0 is
+1524    /// the video stream and the negotiated source comes back soundless.
+1525    #[serde(skip_serializing_if = "Option::is_none")]
+1526    audio_stream_index: Option<i32>,
+1527    #[serde(skip_serializing_if = "Option::is_none")]
+1528    subtitle_stream_index: Option<i32>,
+1529    start_time_ticks: i64,
+1530    is_playback: bool,
+1531    auto_open_live_stream: bool,
+1532    max_streaming_bitrate: i64,
+1533    #[serde(skip_serializing_if = "Option::is_none")]
+1534    device_profile: Option<DeviceProfile>,
+1535}
+1536
+1537#[derive(Debug, Serialize)]
+1538#[serde(rename_all = "PascalCase")]
+1539struct DeviceProfile {
+1540    name: String,
+1541    max_streaming_bitrate: i64,
+1542    max_static_bitrate: i64,
+1543    /// Channels the device's audio route can actually voice. Without it
+1544    /// the server may direct-play a 5.1 track to a two-channel sink,
+1545    /// which is silence or inaudible dialogue depending on the device.
+1546    max_audio_channels: String,
+1547    direct_play_profiles: Vec<DirectPlayProfile>,
+1548    transcoding_profiles: Vec<TranscodingProfile>,
+1549    subtitle_profiles: Vec<SubtitleProfile>,
+1550}
+1551
+1552#[derive(Debug, Serialize)]
+1553#[serde(rename_all = "PascalCase")]
+1554struct DirectPlayProfile {
+1555    #[serde(rename = "Type")]
+1556    profile_type: String,
+1557    container: String,
+1558    #[serde(skip_serializing_if = "Option::is_none")]
+1559    video_codec: Option<String>,
+1560    audio_codec: String,
+1561}
+1562
+1563#[derive(Debug, Serialize)]
+1564#[serde(rename_all = "PascalCase")]
+1565struct TranscodingProfile {
+1566    #[serde(rename = "Type")]
+1567    profile_type: String,
+1568    context: String,
+1569    protocol: String,
+1570    container: String,
+1571    #[serde(skip_serializing_if = "Option::is_none")]
+1572    video_codec: Option<String>,
+1573    audio_codec: String,
+1574    max_audio_channels: String,
+1575}
+1576
+1577#[derive(Debug, Serialize)]
+1578#[serde(rename_all = "PascalCase")]
+1579struct SubtitleProfile {
+1580    format: String,
+1581    method: String,
+1582}
+1583
+1584#[derive(Debug, Deserialize)]
+1585#[serde(rename_all = "PascalCase")]
+1586struct PlaybackInfoResponse {
+1587    media_sources: Vec<NegotiatedSource>,
+1588    play_session_id: String,
+1589}
+1590
+1591#[derive(Debug, Deserialize)]
+1592#[serde(rename_all = "PascalCase")]
+1593pub struct NegotiatedSource {
+1594    pub id: String,
+1595    pub supports_direct_play: bool,
+1596    /// The container can be repackaged without re-encoding — a remux. Distinct
+1597    /// from direct play (which copies the file untouched) and from transcoding
+1598    /// (which spends encoder time); the distinction is what
+1599    /// [`PlaybackKind`](super::stream_selection::PlaybackKind) reports, and it is
+1600    /// the difference between "costs the server nothing" and "costs it a core".
+1601    #[serde(default)]
+1602    pub supports_direct_stream: bool,
+1603    pub supports_transcoding: bool,
+1604    pub transcoding_url: Option<String>,
+1605    /// The source's own bitrate, when the server reports one.
+1606    ///
+1607    /// Fills the quality picker's "this rung is the same as Original" judgement
+1608    /// (DR-227). Absent for some containers — the sampled library has `avi`
+1609    /// files with no bitrate at all — in which case nothing is judged redundant
+1610    /// and every rung stays offered.
+1611    #[serde(default)]
+1612    pub bitrate: Option<i64>,
+1613    #[serde(default)]
+1614    pub media_streams: Vec<NegotiatedStream>,
+1615}
+1616
+1617#[derive(Debug, Deserialize)]
+1618#[serde(rename_all = "PascalCase")]
+1619pub struct NegotiatedStream {
+1620    #[serde(rename = "Type")]
+1621    stream_type: String,
+1622    #[serde(default)]
+1623    index: i32,
+1624    #[serde(default)]
+1625    codec: Option<String>,
+1626    /// The track the server serves when the client pins none.
+1627    #[serde(default)]
+1628    is_default: bool,
+1629}
+1630
+1631/// The direct-play / direct-stream / transcode decision.
+1632///
+1633/// A free function, and pure, so every branch can be tested against
+1634/// `PlaybackInfo` fixtures without a server standing behind it.
+1635///
+1636/// Order matters: the two client-side overrides come first, because both
+1637/// describe cases where the *server's* answer is right about the file and wrong
+1638/// about what this app will do with it. The server judges the file against the
+1639/// profile we sent; it cannot know that this renderer will be handed the audio
+1640/// separately, or that the viewer has pinned a track the file does not default
+1641/// to.
+1642///
+1643/// TRACES: UR-079 | DR-228 | UT-213
+1644pub fn decide_playback_kind(
+1645    source: &NegotiatedSource,
+1646    audio_forces_transcode: bool,
+1647    audio_track_pinned: bool,
+1648) -> PlaybackKind {
+1649    if audio_forces_transcode {
+1650        warn!(
+1651            "[StreamSelection] Server offered direct play for audio this renderer cannot decode — forcing a transcode"
+1652        );
+1653        return PlaybackKind::Transcode;
+1654    }
+1655    if audio_track_pinned {
+1656        // Not a defect in the server's answer — a different question. The
+1657        // file has one default track; the viewer asked for another.
+1658        return PlaybackKind::Transcode;
+1659    }
+1660    if source.supports_direct_play {
+1661        PlaybackKind::DirectPlay
+1662    } else if source.supports_direct_stream {
+1663        PlaybackKind::DirectStream
+1664    } else {
+1665        PlaybackKind::Transcode
+1666    }
+1667}
+1668
+1669#[async_trait]
+1670impl MediaRepository for OnlineRepository {
+1671    async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
+1672        #[derive(Debug, Deserialize)]
+1673        #[serde(rename_all = "PascalCase")]
+1674        struct LibrariesResponse {
+1675            items: Vec<JellyfinLibrary>,
+1676        }
+1677
+1678        #[derive(Debug, Deserialize)]
+1679        #[serde(rename_all = "PascalCase")]
+1680        struct JellyfinLibrary {
+1681            id: String,
+1682            name: String,
+1683            collection_type: Option<String>,
+1684            image_tags: Option<ImageTags>,
+1685        }
+1686
+1687        let endpoint = format!("/Users/{}/Views", self.user_id);
+1688        let response: LibrariesResponse = self.get_json(&endpoint).await?;
+1689
+1690        Ok(response
+1691            .items
+1692            .into_iter()
+1693            .map(|lib| {
+1694                Library::new(
+1695                    lib.id,
+1696                    lib.name,
+1697                    lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
+1698                    lib.image_tags.and_then(|tags| tags.primary()),
+1699                )
+1700            })
+1701            .collect())
+1702    }
+1703
+1704    async fn get_items(
+1705        &self,
+1706        parent_id: &str,
+1707        options: Option<GetItemsOptions>,
+1708    ) -> Result<SearchResult, RepoError> {
+1709        let endpoint = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());
+1710
+1711        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1712
+1713        Ok(SearchResult {
+1714            items: response
+1715                .items
+1716                .into_iter()
+1717                .map(|item| item.into_media_item(self.user_id.clone()))
+1718                .collect(),
+1719            total_record_count: response.total_record_count,
+1720        })
+1721    }
+1722
+1723    /// Fetch one item with every field the detail and player screens need.
+1724    ///
+1725    /// The `Fields=` list is the load-bearing part: Jellyfin omits these unless
+1726    /// they are named. `MediaStreams` is what makes the item's **audio and
+1727    /// subtitle tracks** knowable at all — there is no separate "tracks"
+1728    /// endpoint, so this single call is how the player learns which audio tracks
+1729    /// an item offers (`to_media_item` maps them, and the player's selector
+1730    /// filters them by `kind`). `People` is likewise how **cast and crew** are
+1731    /// obtained.
+1732    ///
+1733    /// TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009
+1734    async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
+1735        let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, urlencoding::encode(item_id));
+1736
+1737        let item: JellyfinItem = self.get_json(&endpoint).await?;
+1738        let media_item = item.into_media_item(self.user_id.clone());
+1739
+1740        Ok(media_item)
+1741    }
+1742
+1743    async fn get_latest_items(
+1744        &self,
+1745        parent_id: &str,
+1746        limit: Option<usize>,
+1747    ) -> Result<Vec<MediaItem>, RepoError> {
+1748        let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
+1749
+1750        let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
+1751        Ok(items
+1752            .into_iter()
+1753            .map(|item| item.into_media_item(self.user_id.clone()))
+1754            .collect())
+1755    }
+1756
+1757    /// Continue Watching: the items this user has started and not finished.
+1758    ///
+1759    /// `/Users/{uid}/Items/Resume` is the server-side answer to both "what goes
+1760    /// in the Continue Watching row" and "where was this left off" — each item
+1761    /// carries its own `UserData.PlaybackPositionTicks`, which is why `UserData`
+1762    /// is named in `Fields=` rather than left to the server's default field set.
+1763    ///
+1764    /// TRACES: UR-019, UR-023 | IR-024, JA-013, JA-015
+1765    async fn get_resume_items(
+1766        &self,
+1767        parent_id: Option<&str>,
+1768        limit: Option<usize>,
+1769    ) -> Result<Vec<MediaItem>, RepoError> {
+1770        let limit_str = limit.unwrap_or(16);
+1771        let mut endpoint = format!(
+1772            "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+1773            self.user_id, limit_str
+1774        );
+1775
+1776        if let Some(pid) = parent_id {
+1777            endpoint.push_str(&format!("&ParentId={}", pid));
+1778        }
+1779
+1780        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1781        Ok(response
+1782            .items
+1783            .into_iter()
+1784            .map(|item| item.into_media_item(self.user_id.clone()))
+1785            .collect())
+1786    }
+1787
+1788    /// "Next Up": the episode that follows the ones this user has finished,
+1789    /// per series — the Shows-scoped counterpart to Continue Watching.
+1790    ///
+1791    /// TRACES: UR-023, UR-059 | IR-024, JA-014
+1792    async fn get_next_up_episodes(
+1793        &self,
+1794        series_id: Option<&str>,
+1795        limit: Option<usize>,
+1796    ) -> Result<Vec<MediaItem>, RepoError> {
+1797        let endpoint = build_next_up_endpoint(&self.user_id, series_id, limit);
+1798
+1799        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1800        Ok(response
+1801            .items
+1802            .into_iter()
+1803            .map(|item| item.into_media_item(self.user_id.clone()))
+1804            .collect())
+1805    }
+1806
+1807    async fn get_recently_played_audio(
+1808        &self,
+1809        limit: Option<usize>,
+1810    ) -> Result<Vec<MediaItem>, RepoError> {
+1811        let limit_val = limit.unwrap_or(12);
+1812        // Fetch more items to account for grouping reducing the count
+1813        let fetch_limit = limit_val * 3;
+1814        let endpoint = format!(
+1815            "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+1816            self.user_id, fetch_limit
+1817        );
+1818
+1819        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1820        let items: Vec<MediaItem> = response
+1821            .items
+1822            .into_iter()
+1823            .map(|item| item.into_media_item(self.user_id.clone()))
+1824            .collect();
+1825
+1826        debug!("[get_recently_played_audio] Fetched {} items", items.len());
+1827        for item in &items {
+1828            debug!("[get_recently_played_audio] Item: name={}, type={}, album_id={:?}, album_name={:?}",
+1829                item.name, item.item_type, item.album_id, item.album_name);
+1830        }
+1831
+1832        // Group by album - create pseudo-album entries for tracks with same albumId
+1833        use std::collections::BTreeMap;
+1834        let mut album_map: BTreeMap<String, Vec<MediaItem>> = BTreeMap::new();
+1835        let mut ungrouped = Vec::new();
+1836
+1837        for item in items {
+1838            // Use album_id if available, fall back to album_name for grouping
+1839            let group_key = item.album_id.clone().or_else(|| item.album_name.clone());
+1840
+1841            if let Some(key) = group_key {
+1842                debug!(
+1843                    "[get_recently_played_audio] Grouping item '{}' into album '{}'",
+1844                    item.name, key
+1845                );
+1846                album_map.entry(key).or_default().push(item);
+1847            } else {
+1848                debug!(
+1849                    "[get_recently_played_audio] No album_id or album_name for item: '{}'",
+1850                    item.name
+1851                );
+1852                ungrouped.push(item);
+1853            }
+1854        }
+1855
+1856        // Create album entries from grouped tracks
+1857        let mut result: Vec<MediaItem> = album_map
+1858            .into_iter()
+1859            .map(|(album_id, tracks)| {
+1860                let first_track = &tracks[0];
+1861                let most_recent = tracks
+1862                    .iter()
+1863                    .max_by(|a, b| {
+1864                        let date_a = a
+1865                            .user_data
+1866                            .as_ref()
+1867                            .and_then(|ud| ud.last_played_date.as_deref())
+1868                            .unwrap_or("");
+1869                        let date_b = b
+1870                            .user_data
+1871                            .as_ref()
+1872                            .and_then(|ud| ud.last_played_date.as_deref())
+1873                            .unwrap_or("");
+1874                        date_b.cmp(date_a)
+1875                    })
+1876                    .unwrap_or(first_track);
+1877
+1878                MediaItem {
+1879                    id: album_id,
+1880                    name: first_track
+1881                        .album_name
+1882                        .clone()
+1883                        .unwrap_or_else(|| "Unknown Album".to_string()),
+1884                    item_type: "MusicAlbum".to_string(),
+1885                    kind: crate::domain::MediaKind::Album,
+1886                    is_folder: true,
+1887                    server_id: first_track.server_id.clone(),
+1888                    parent_id: None,
+1889                    library_id: None,
+1890                    overview: None,
+1891                    genres: None,
+1892                    production_year: None,
+1893                    premiere_date: None,
+1894                    community_rating: None,
+1895                    official_rating: None,
+1896                    runtime_ticks: None,
+1897                    duration_ms: None,
+1898                    primary_image_tag: first_track.primary_image_tag.clone(),
+1899                    image_id: first_track.primary_image_tag.clone(),
+1900                    backdrop_image_tags: None,
+1901                    parent_backdrop_image_tags: None,
+1902                    album_id: None,
+1903                    album_name: None,
+1904                    album_artist: None,
+1905                    artists: first_track.artists.clone(),
+1906                    artist_items: first_track.artist_items.clone(),
+1907                    index_number: None,
+1908                    parent_index_number: None,
+1909                    series_id: None,
+1910                    series_name: None,
+1911                    season_id: None,
+1912                    season_name: None,
+1913                    user_data: most_recent.user_data.clone(),
+1914                    media_streams: None,
+1915                    media_sources: None,
+1916                    people: None,
+1917                }
+1918            })
+1919            .collect();
+1920
+1921        // Append ungrouped tracks
+1922        result.extend(ungrouped);
+1923
+1924        // Return only the requested limit
+1925        let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
+1926        debug!(
+1927            "[get_recently_played_audio] Returning {} items after grouping",
+1928            final_result.len()
+1929        );
+1930        for item in &final_result {
+1931            debug!(
+1932                "[get_recently_played_audio] Return: name={}, type={}",
+1933                item.name, item.item_type
+1934            );
+1935        }
+1936        Ok(final_result)
+1937    }
+1938
+1939    async fn get_rediscover_albums(
+1940        &self,
+1941        parent_id: Option<&str>,
+1942        limit: Option<usize>,
+1943    ) -> Result<Vec<MediaItem>, RepoError> {
+1944        let limit_val = limit.unwrap_or(12);
+1945        // Ask Jellyfin for played albums sorted by least-recently played first.
+1946        // Filters=IsPlayed keeps only albums the user has actually listened to,
+1947        // and SortBy=DatePlayed ascending surfaces the ones they've neglected.
+1948        let mut endpoint = format!(
+1949            "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+1950            self.user_id, limit_val
+1951        );
+1952
+1953        if let Some(pid) = parent_id {
+1954            endpoint.push_str(&format!("&ParentId={}", pid));
+1955        }
+1956
+1957        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1958        Ok(response
+1959            .items
+1960            .into_iter()
+1961            .map(|item| item.into_media_item(self.user_id.clone()))
+1962            .collect())
+1963    }
+1964
+1965    /// Continue Watching, narrowed to movies — the home screen's movie row and
+1966    /// the movie library's own hero both want the unfinished films without the
+1967    /// episodes mixed in.
+1968    ///
+1969    /// TRACES: UR-019, UR-034 | IR-024, JA-013, JA-015
+1970    async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
+1971        let limit_str = limit.unwrap_or(16);
+1972        let endpoint = format!(
+1973            "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+1974            self.user_id, limit_str
+1975        );
+1976
+1977        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1978        Ok(response
+1979            .items
+1980            .into_iter()
+1981            .map(|item| item.into_media_item(self.user_id.clone()))
+1982            .collect())
+1983    }
+1984
+1985    async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
+1986        // Ask Jellyfin to scope counts to albums and include them, so the
+1987        // frontend can rank genres by popularity without probing each one.
+1988        let mut endpoint = format!(
+1989            "/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
+1990            self.user_id
+1991        );
+1992
+1993        if let Some(pid) = parent_id {
+1994            endpoint.push_str(&format!("&ParentId={}", pid));
+1995        }
+1996
+1997        #[derive(Debug, Deserialize)]
+1998        #[serde(rename_all = "PascalCase")]
+1999        struct GenresResponse {
+2000            items: Vec<JellyfinGenre>,
+2001        }
+2002
+2003        #[derive(Debug, Deserialize)]
+2004        #[serde(rename_all = "PascalCase")]
+2005        struct JellyfinGenre {
+2006            id: String,
+2007            name: String,
+2008            // Which count field Jellyfin populates for a genre under
+2009            // Fields=ItemCounts varies by server/version: scoped queries may
+2010            // fill AlbumCount, others only ChildCount. Read whichever is
+2011            // present so ranking still works. Absent on servers that ignore
+2012            // Fields=ItemCounts entirely, so all stay optional.
+2013            album_count: Option<u32>,
+2014            child_count: Option<u32>,
+2015        }
+2016
+2017        let response: GenresResponse = self.get_json(&endpoint).await?;
+2018        let genres: Vec<Genre> = response
+2019            .items
+2020            .into_iter()
+2021            .map(|g| Genre {
+2022                id: g.id,
+2023                name: g.name,
+2024                album_count: g.album_count.or(g.child_count),
+2025            })
+2026            .collect();
+2027
+2028        let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
+2029        // TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
+2030        // see whether the server populates any count field. Remove once known.
+2031        log::warn!(
+2032            "get_genres: {} genres, {} carry counts. sample: {:?}",
+2033            genres.len(),
+2034            with_counts,
+2035            genres
+2036                .iter()
+2037                .take(8)
+2038                .map(|g| (g.name.as_str(), g.album_count))
+2039                .collect::<Vec<_>>()
+2040        );
+2041
+2042        Ok(genres)
+2043    }
+2044
+2045    /// Search every library the user can see.
+2046    ///
+2047    /// `Recursive=true` with no `ParentId` is what makes this cross-library
+2048    /// rather than folder-scoped; a caller narrowing the search passes the item
+2049    /// types through `SearchOptions` (already expanded from an opaque
+2050    /// `SearchScope` on this side of the boundary).
+2051    ///
+2052    /// TRACES: UR-008 | IR-010, JA-006
+2053    async fn search(
+2054        &self,
+2055        query: &str,
+2056        options: Option<SearchOptions>,
+2057    ) -> Result<SearchResult, RepoError> {
+2058        let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
+2059        // SearchTerm is arbitrary user input and must be percent-encoded so that
+2060        // spaces, ampersands, etc. don't corrupt the query string (a multi-word
+2061        // search like "Star Wars" would otherwise produce a malformed URL).
+2062        let mut endpoint = format!(
+2063            "/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
+2064            self.user_id,
+2065            urlencoding::encode(query),
+2066            limit
+2067        );
+2068
+2069        if let Some(opts) = options {
+2070            if let Some(types) = opts.include_item_types {
+2071                let encoded_types = types
+2072                    .iter()
+2073                    .map(|t| urlencoding::encode(t).into_owned())
+2074                    .collect::<Vec<_>>()
+2075                    .join(",");
+2076                endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
+2077            }
+2078        }
+2079
+2080        // Request image fields for list views (plus Genres so cached items
+2081        // carry genres for offline genre lists/counts).
+2082        endpoint.push_str(
+2083            "&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData",
+2084        );
+2085
+2086        let response: ItemsResponse = self.get_json(&endpoint).await?;
+2087        Ok(SearchResult {
+2088            items: response
+2089                .items
+2090                .into_iter()
+2091                .map(|item| item.into_media_item(self.user_id.clone()))
+2092                .collect(),
+2093            total_record_count: response.total_record_count,
+2094        })
+2095    }
+2096
+2097    async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
+2098        let (source, play_session_id) = self.negotiate_playback(item_id).await?;
+2099
+2100        // Log available media streams for debugging
+2101        info!(
+2102            "PlaybackInfo MediaSource has {} streams",
+2103            source.media_streams.len()
+2104        );
+2105        for stream in &source.media_streams {
+2106            info!(
+2107                "  Stream type={}, index={}, codec={:?}",
+2108                stream.stream_type, stream.index, stream.codec
+2109            );
+2110        }
+2111
+2112        // Name the tracks we are declining to have the server composite. Burn-in
+2113        // rules out remuxing the video, so a single image-based track can turn a
+2114        // free passthrough into a full re-encode; when that used to happen there
+2115        // was nothing in the log connecting the stall to the subtitle.
+2116        for stream in &source.media_streams {
+2117            if stream.stream_type == "Subtitle" {
+2118                if let Some(codec) = stream.codec.as_deref() {
+2119                    if super::device_profile::subtitle_forces_burn_in(codec) {
+2120                        info!(
+2121                            "  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)",
+2122                            stream.index, codec
+2123                        );
+2124                    }
+2125                }
+2126            }
+2127        }
+2128
+2129        // Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec
+2130        // but ignores its audio codec, so it offers an E-AC-3 track for direct
+2131        // play even though DR-148 advertises only AAC — and the webview renders
+2132        // the picture in silence. Judge the track we would actually be served
+2133        // against what the webview can decode, and override the server's answer.
+2134        let audio_streams: Vec<(Option<&str>, bool)> = source
+2135            .media_streams
+2136            .iter()
+2137            .filter(|stream| stream.stream_type == "Audio")
+2138            .map(|stream| (stream.codec.as_deref(), stream.is_default))
+2139            .collect();
+2140        let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
+2141
+2142        // Use TranscodingUrl from response if available (Streamyfin pattern)
+2143        let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
+2144            // The server started this job and named the session — adopt it, or a
+2145            // later quality switch / seek on this stream has no previous job to
+2146            // stop and ends up contending with the one currently playing.
+2147            if let Some(previous) = adopt_video_play_session(play_session_id.clone()) {
+2148                self.stop_transcode(&previous).await;
+2149            }
+2150            // The server built this URL from its *own* subtitle verdict, so it can
+2151            // hand back the burn-in the request above just declined. Strip it: the
+2152            // negotiated answer only holds for the stream we actually open.
+2153            //
+2154            // TRACES: UR-020, UR-004 | DR-176 | UT-168
+2155            format!(
+2156                "{}{}",
+2157                self.server_url,
+2158                super::device_profile::without_server_chosen_subtitle(transcoding_url)
+2159            )
+2160        } else if audio_forces_transcode {
+2161            warn!(
+2162                "[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
+2163                audio_streams.first().and_then(|(codec, _)| *codec)
+2164            );
+2165            self.get_video_stream_url(item_id, Some(&source.id), None)
+2166                .await?
+2167        } else {
+2168            // Fall back to direct stream URL. No audioStreamIndex: static=true
+2169            // serves the original file untouched, and pinning index 0 (the video
+2170            // stream) only misleads servers that do honour it.
+2171            format!(
+2172                "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&userId={}",
+2173                self.server_url,
+2174                item_id,
+2175                source.id,
+2176                self.access_token,
+2177                self.user_id
+2178            )
+2179        };
+2180
+2181        info!("Final stream URL: {}", stream_url);
+2182
+2183        Ok(PlaybackInfo {
+2184            media_source_id: source.id.clone(),
+2185            play_session_id,
+2186            stream_url,
+2187            direct_play: source.supports_direct_play && !audio_forces_transcode,
+2188            needs_transcoding: audio_forces_transcode
+2189                || (!source.supports_direct_play && source.supports_transcoding),
+2190        })
+2191    }
+2192
+2193    async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
+2194        // Construct direct audio stream URL
+2195        let url = format!(
+2196            "{}/Audio/{}/stream?UserId={}&api_key={}&Static=true",
+2197            self.server_url, item_id, self.user_id, self.access_token
+2198        );
+2199        Ok(url)
+2200    }
+2201
+2202    async fn get_audio_only_stream_url_for_video(
+2203        &self,
+2204        item_id: &str,
+2205        media_source_id: Option<&str>,
+2206        start_time_seconds: Option<f64>,
+2207        audio_stream_index: Option<i32>,
+2208    ) -> Result<String, RepoError> {
+2209        self.build_audio_only_stream_url_for_video(
+2210            item_id,
+2211            media_source_id,
+2212            start_time_seconds,
+2213            audio_stream_index,
+2214        )
+2215        .await
+2216    }
+2217
+2218    async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
+2219        // Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
+2220        // type "TvChannel" — playable via open_live_stream.
+2221        let endpoint = format!(
+2222            "/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
+2223            self.user_id
+2224        );
+2225        let response: ItemsResponse = self.get_json(&endpoint).await?;
+2226        Ok(response
+2227            .items
+2228            .into_iter()
+2229            .map(|item| item.into_media_item(self.server_url.clone()))
+2230            .collect())
+2231    }
+2232
+2233    async fn get_channels(&self) -> Result<SearchResult, RepoError> {
+2234        // Root list of plugin "Channels". Drill-down into a channel folder reuses
+2235        // get_items(channel_id, ...).
+2236        let endpoint = format!("/Channels?UserId={}", self.user_id);
+2237        let response: ItemsResponse = self.get_json(&endpoint).await?;
+2238        let total = response.total_record_count;
+2239        let items = response
+2240            .items
+2241            .into_iter()
+2242            .map(|item| item.into_media_item(self.server_url.clone()))
+2243            .collect();
+2244        Ok(SearchResult {
+2245            items,
+2246            total_record_count: total,
+2247        })
+2248    }
+2249
+2250    async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
+2251        // Live channels require a PlaybackInfo call with AutoOpenLiveStream so the
+2252        // server opens the live stream and returns a ready-to-play transcoding URL.
+2253        // We send a minimal request; the server applies its own defaults for live.
+2254        #[derive(Debug, Serialize)]
+2255        #[serde(rename_all = "PascalCase")]
+2256        struct OpenLiveStreamRequest {
+2257            user_id: String,
+2258            #[serde(rename = "AutoOpenLiveStream")]
+2259            auto_open_live_stream: bool,
+2260            is_playback: bool,
+2261            max_streaming_bitrate: u64,
+2262            /// "No subtitle", for the same reason as everywhere else: omitting it
+2263            /// lets the server apply the channel's default track, and broadcast
+2264            /// subtitles are DVB bitmaps — deliverable only by burning them in,
+2265            /// which forces a full re-encode of a stream that is already tight.
+2266            ///
+2267            /// TRACES: UR-020, UR-004 | DR-176 | UT-168
+2268            subtitle_stream_index: i32,
+2269        }
+2270
+2271        #[derive(Debug, Deserialize)]
+2272        #[serde(rename_all = "PascalCase")]
+2273        struct OpenLiveStreamResponse {
+2274            #[serde(default)]
+2275            media_sources: Vec<LiveMediaSource>,
+2276            play_session_id: Option<String>,
+2277        }
+2278
+2279        #[derive(Debug, Deserialize)]
+2280        #[serde(rename_all = "PascalCase")]
+2281        struct LiveMediaSource {
+2282            id: String,
+2283            transcoding_url: Option<String>,
+2284            live_stream_id: Option<String>,
+2285        }
+2286
+2287        let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
+2288        let request = OpenLiveStreamRequest {
+2289            user_id: self.user_id.clone(),
+2290            auto_open_live_stream: true,
+2291            is_playback: true,
+2292            // Live TV is video like any other, so the user's cap applies here
+2293            // too — a channel opened at the source bitrate would walk straight
+2294            // past a limit set for the connection. TRACES: UR-074 | DR-162
+2295            max_streaming_bitrate: effective_streaming_quality()
+2296                .max_bitrate()
+2297                .unwrap_or(20_000_000),
+2298            subtitle_stream_index: super::device_profile::playback_subtitle_stream_index(),
+2299        };
+2300
+2301        let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
+2302
+2303        let source = response
+2304            .media_sources
+2305            .into_iter()
+2306            .next()
+2307            .ok_or(RepoError::NotFound {
+2308                message: "No live media source returned".to_string(),
+2309            })?;
+2310
+2311        // The transcoding URL is server-relative; make it absolute. If the server
+2312        // did not provide one (rare for live), fall back to the HLS master endpoint.
+2313        let stream_url = match source.transcoding_url {
+2314            // As in `get_playback_info`: the server chose the subtitle in this
+2315            // URL, so decline it here too. TRACES: UR-020 | DR-176 | UT-168
+2316            Some(url) => format!(
+2317                "{}{}",
+2318                self.server_url,
+2319                super::device_profile::without_server_chosen_subtitle(&url)
+2320            ),
+2321            None => format!(
+2322                "{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
+2323                self.server_url,
+2324                item_id,
+2325                self.access_token,
+2326                source.id,
+2327                source.live_stream_id.clone().unwrap_or_default(),
+2328                super::device_profile::playback_subtitle_stream_index(),
+2329            ),
+2330        };
+2331
+2332        Ok(LiveStreamInfo {
+2333            stream_url,
+2334            play_session_id: response.play_session_id,
+2335            live_stream_id: source.live_stream_id,
+2336            media_source_id: Some(source.id),
+2337            // Both branches above produce a playlist: the server's own
+2338            // TranscodingUrl, or our `master.m3u8` fallback.
+2339            transport: Transport::Hls,
+2340        })
+2341    }
+2342
+2343    async fn report_playback_start(
+2344        &self,
+2345        item_id: &str,
+2346        position_ticks: i64,
+2347    ) -> Result<(), RepoError> {
+2348        #[derive(Serialize)]
+2349        #[serde(rename_all = "PascalCase")]
+2350        struct PlaybackStartRequest {
+2351            item_id: String,
+2352            position_ticks: i64,
+2353            play_command: String,
+2354            is_paused: bool,
+2355        }
+2356
+2357        let request = PlaybackStartRequest {
+2358            item_id: item_id.to_string(),
+2359            position_ticks,
+2360            play_command: "PlayNow".to_string(),
+2361            is_paused: false,
+2362        };
+2363
+2364        self.post_json("/Sessions/Playing", &request).await
+2365    }
+2366
+2367    async fn report_playback_progress(
+2368        &self,
+2369        item_id: &str,
+2370        position_ticks: i64,
+2371    ) -> Result<(), RepoError> {
+2372        #[derive(Serialize)]
+2373        #[serde(rename_all = "PascalCase")]
+2374        struct PlaybackProgressRequest {
+2375            item_id: String,
+2376            position_ticks: i64,
+2377            is_paused: bool,
+2378        }
+2379
+2380        let request = PlaybackProgressRequest {
+2381            item_id: item_id.to_string(),
+2382            position_ticks,
+2383            is_paused: false,
+2384        };
+2385
+2386        self.post_json("/Sessions/Playing/Progress", &request).await
+2387    }
+2388
+2389    async fn report_playback_stopped(
+2390        &self,
+2391        item_id: &str,
+2392        position_ticks: i64,
+2393    ) -> Result<(), RepoError> {
+2394        #[derive(Serialize)]
+2395        #[serde(rename_all = "PascalCase")]
+2396        struct PlaybackStoppedRequest {
+2397            item_id: String,
+2398            position_ticks: i64,
+2399        }
+2400
+2401        let request = PlaybackStoppedRequest {
+2402            item_id: item_id.to_string(),
+2403            position_ticks,
+2404        };
+2405
+2406        self.post_json("/Sessions/Playing/Stopped", &request).await
+2407    }
+2408
+2409    fn get_image_url(
+2410        &self,
+2411        item_id: &str,
+2412        image_type: ImageType,
+2413        options: Option<ImageOptions>,
+2414    ) -> String {
+2415        let mut url = format!(
+2416            "{}/Items/{}/Images/{}",
+2417            self.server_url,
+2418            item_id,
+2419            image_type.as_str()
+2420        );
+2421
+2422        // Authentication is handled by X-Emby-Authorization header in download_bytes()
+2423        // Do NOT include api_key here — some Jellyfin servers reject requests when
+2424        // api_key is present but the token doesn't match the expected format.
+2425        let mut params: Vec<String> = Vec::new();
+2426
+2427        if let Some(opts) = options {
+2428            if let Some(width) = opts.max_width {
+2429                params.push(format!("maxWidth={}", width));
+2430            }
+2431            if let Some(height) = opts.max_height {
+2432                params.push(format!("maxHeight={}", height));
+2433            }
+2434            if let Some(quality) = opts.quality {
+2435                params.push(format!("quality={}", quality));
+2436            }
+2437            if let Some(tag) = opts.tag {
+2438                params.push(format!("tag={}", tag));
+2439            }
+2440        }
+2441
+2442        if !params.is_empty() {
+2443            url.push('?');
+2444            url.push_str(&params.join("&"));
+2445        }
+2446
+2447        url
+2448    }
+2449
+2450    fn get_subtitle_url(
+2451        &self,
+2452        item_id: &str,
+2453        media_source_id: &str,
+2454        stream_index: i32,
+2455        format: &str,
+2456    ) -> String {
+2457        format!(
+2458            "{}/Videos/{}/{}/Subtitles/{}/{}",
+2459            self.server_url, item_id, media_source_id, stream_index, format
+2460        )
+2461    }
+2462
+2463    /// TRACES: UR-071 | DR-123
+2464    fn get_video_download_url(
+2465        &self,
+2466        item_id: &str,
+2467        quality: &str,
+2468        media_source_id: Option<&str>,
+2469        source_audio_codec: Option<&str>,
+2470    ) -> String {
+2471        // NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
+2472        // available (returns 404 on many server configs), which silently broke
+2473        // every movie/TV download. Use the progressive `stream.mp4` endpoint
+2474        // instead — it is always present and supports HTTP Range, which the
+2475        // download worker relies on for resume.
+2476        let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
+2477        let mut params = vec![format!("api_key={}", self.access_token)];
+2478
+2479        // Map the frontend quality preset to concrete transcode params. For
+2480        // "original" we request a direct static copy (no transcode) which is
+2481        // byte-range resumable; other presets ask the server to transcode.
+2482        //
+2483        // 🔴 It is `videoBitRate`/`audioBitRate` — **capital R**. Jellyfin binds
+2484        // query keys case-insensitively, so `maxHeight`/`videoCodec` casing is
+2485        // free, but `videoBitrate` (lowercase r) is a *different token*: it
+2486        // fails to bind, is silently dropped, and the requested cap vanishes
+2487        // with no error. That is why every "480p"/"720p" download came back at
+2488        // full original quality. See `Jellyfin.Api` BaseEncodingJobOptions.
+2489        //
+2490        // `allowVideoStreamCopy=false` forces a real re-encode. Without it the
+2491        // server may stream-copy the source when it already satisfies the cap —
+2492        // fine in itself, but it also means a mis-typed cap degrades silently.
+2493        // Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
+2494        // video copy is gated by `allowVideoStreamCopy`.
+2495        match quality {
+2496            "high" => {
+2497                params.push("videoBitRate=8000000".to_string());
+2498                params.push("maxHeight=1080".to_string());
+2499                params.push("audioBitRate=384000".to_string());
+2500                params.push("videoCodec=h264".to_string());
+2501                params.push("audioCodec=aac".to_string());
+2502                params.push("allowVideoStreamCopy=false".to_string());
+2503            }
+2504            "medium" => {
+2505                params.push("videoBitRate=4000000".to_string());
+2506                params.push("maxHeight=720".to_string());
+2507                params.push("audioBitRate=256000".to_string());
+2508                params.push("videoCodec=h264".to_string());
+2509                params.push("audioCodec=aac".to_string());
+2510                params.push("allowVideoStreamCopy=false".to_string());
+2511            }
+2512            "low" => {
+2513                params.push("videoBitRate=1500000".to_string());
+2514                params.push("maxHeight=480".to_string());
+2515                params.push("audioBitRate=128000".to_string());
+2516                params.push("videoCodec=h264".to_string());
+2517                params.push("audioCodec=aac".to_string());
+2518                params.push("allowVideoStreamCopy=false".to_string());
+2519            }
+2520            // "original" (and any unknown value) → direct, resumable copy —
+2521            // unless the audio in that copy is undecodable where the file will
+2522            // be played back. A download is watched with no server in reach, so
+2523            // it has to satisfy the same constraint DR-149 applies to streams:
+2524            // the webview `<video>` element renders video on both platforms and
+2525            // decodes none of AC-3/E-AC-3/DTS/TrueHD. Copying those bytes to
+2526            // disk is what made a downloaded film play offline as picture with
+2527            // no sound while the same film had sound when streamed.
+2528            //
+2529            // Only the *audio* is re-encoded. `allowVideoStreamCopy` keeps an
+2530            // h264 source's picture byte-for-byte, so "original" still means
+2531            // original quality, and no bitrate or resolution cap is added. A
+2532            // source the webview could not have rendered anyway (HEVC) is
+2533            // re-encoded to h264 as a side effect, which is the only form of it
+2534            // that would have played.
+2535            //
+2536            // The cost of the transcode is that the response is no longer
+2537            // range-resumable, which is exactly why this is decided per item
+2538            // rather than applied to every `original` download.
+2539            //
+2540            // TRACES: UR-071, UR-004 | DR-171 | UT-166
+2541            _ => match source_audio_codec {
+2542                Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
+2543                    params.push("videoCodec=h264".to_string());
+2544                    params.push("allowVideoStreamCopy=true".to_string());
+2545                    params.push("audioCodec=aac".to_string());
+2546                    params.push("audioBitRate=384000".to_string());
+2547                }
+2548                // Decodable, or unknown: an unknown codec must not provoke a
+2549                // transcode — that would burn server CPU on a guess for files
+2550                // that play perfectly well.
+2551                _ => params.push("Static=true".to_string()),
+2552            },
+2553        }
+2554
+2555        // Add media source ID if provided
+2556        if let Some(source_id) = media_source_id {
+2557            params.push(format!("mediaSourceId={}", source_id));
+2558        }
+2559
+2560        url.push('?');
+2561        url.push_str(&params.join("&"));
+2562
+2563        url
+2564    }
+2565
+2566    async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
+2567        let endpoint = format!(
+2568            "/Users/{}/FavoriteItems/{}",
+2569            self.user_id,
+2570            urlencoding::encode(item_id)
+2571        );
+2572        self.post_json(&endpoint, &serde_json::json!({})).await
+2573    }
+2574
+2575    /// TRACES: UR-067 | DR-115, JA-033 | UT-100
+2576    async fn get_favorites(
+2577        &self,
+2578        scope: SearchScope,
+2579        options: Option<GetItemsOptions>,
+2580    ) -> Result<SearchResult, RepoError> {
+2581        let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
+2582        let response: ItemsResponse = self.get_json(&endpoint).await?;
+2583
+2584        Ok(SearchResult {
+2585            items: response
+2586                .items
+2587                .into_iter()
+2588                .map(|item| item.into_media_item(self.user_id.clone()))
+2589                .collect(),
+2590            total_record_count: response.total_record_count,
+2591        })
+2592    }
+2593
+2594    /// Un-favourite an item: the same `/Users/{uid}/FavoriteItems/{id}` resource
+2595    /// as [`Self::mark_favorite`], removed rather than posted. Written out by
+2596    /// hand rather than through `post_json` because it is the one favourite call
+2597    /// that needs `DELETE`.
+2598    ///
+2599    /// TRACES: UR-017 | JA-018, DR-021
+2600    async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
+2601        let endpoint = format!(
+2602            "/Users/{}/FavoriteItems/{}",
+2603            self.user_id,
+2604            urlencoding::encode(item_id)
+2605        );
+2606        let url = format!("{}{}", self.server_url, endpoint);
+2607
+2608        let result = async {
+2609            let request = self
+2610                .http_client
+2611                .client
+2612                .delete(&url)
+2613                .header("X-Emby-Authorization", self.auth_header())
+2614                .build()
+2615                .map_err(|e| RepoError::Network {
+2616                    message: format!("Failed to build request: {}", e),
+2617                })?;
+2618
+2619            let response = self
+2620                .http_client
+2621                .request_with_retry(request)
+2622                .await
+2623                .map_err(|e| RepoError::Network {
+2624                    message: e.to_string(),
+2625                })?;
+2626
+2627            if !response.status().is_success() {
+2628                return Err(RepoError::Server {
+2629                    message: format!("HTTP {}", response.status()),
+2630                });
+2631            }
+2632
+2633            Ok(())
+2634        }
+2635        .await;
+2636
+2637        self.report_outcome(&result).await;
+2638        result
+2639    }
+2640
+2641    /// `DELETE /Users/{userId}/PlayedItems/{itemId}` — Jellyfin's "mark
+2642    /// unplayed", which also zeroes the resume position. On a folder (series,
+2643    /// season) the server applies it recursively to the children.
+2644    ///
+2645    /// TRACES: UR-064 | DR-106, JA-033
+2646    async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
+2647        let endpoint = format!(
+2648            "/Users/{}/PlayedItems/{}",
+2649            self.user_id,
+2650            urlencoding::encode(item_id)
+2651        );
+2652        let url = format!("{}{}", self.server_url, endpoint);
+2653
+2654        let result = async {
+2655            let request = self
+2656                .http_client
+2657                .client
+2658                .delete(&url)
+2659                .header("X-Emby-Authorization", self.auth_header())
+2660                .build()
+2661                .map_err(|e| RepoError::Network {
+2662                    message: format!("Failed to build request: {}", e),
+2663                })?;
+2664
+2665            let response = self
+2666                .http_client
+2667                .request_with_retry(request)
+2668                .await
+2669                .map_err(|e| RepoError::Network {
+2670                    message: e.to_string(),
+2671                })?;
+2672
+2673            if !response.status().is_success() {
+2674                return Err(RepoError::Server {
+2675                    message: format!("HTTP {}", response.status()),
+2676                });
+2677            }
+2678
+2679            Ok(())
+2680        }
+2681        .await;
+2682
+2683        self.report_outcome(&result).await;
+2684        result
+2685    }
+2686
+2687    /// `POST /Users/{userId}/PlayedItems/{itemId}` — the mirror image of
+2688    /// `clear_watch_history`.
+2689    ///
+2690    /// TRACES: UR-025 | DR-131 | JA-035
+2691    async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
+2692        let endpoint = format!(
+2693            "/Users/{}/PlayedItems/{}",
+2694            self.user_id,
+2695            urlencoding::encode(item_id)
+2696        );
+2697        let url = format!("{}{}", self.server_url, endpoint);
+2698
+2699        let result = async {
+2700            let request = self
+2701                .http_client
+2702                .client
+2703                .post(&url)
+2704                .header("X-Emby-Authorization", self.auth_header())
+2705                .header("Content-Length", "0")
+2706                .build()
+2707                .map_err(|e| RepoError::Network {
+2708                    message: format!("Failed to build request: {}", e),
+2709                })?;
+2710
+2711            let response = self
+2712                .http_client
+2713                .request_with_retry(request)
+2714                .await
+2715                .map_err(|e| RepoError::Network {
+2716                    message: e.to_string(),
+2717                })?;
+2718
+2719            if !response.status().is_success() {
+2720                return Err(RepoError::Server {
+2721                    message: format!("HTTP {}", response.status()),
+2722                });
+2723            }
+2724
+2725            Ok(())
+2726        }
+2727        .await;
+2728
+2729        self.report_outcome(&result).await;
+2730        result
+2731    }
+2732
+2733    /// A single Person item (actor, director, …) by id.
+2734    ///
+2735    /// Jellyfin models people as ordinary items, so this is the plain item
+2736    /// endpoint rather than anything under `/Persons`; the cast entries returned
+2737    /// on an item's `People` field carry the ids this is called with.
+2738    ///
+2739    /// TRACES: UR-035, UR-036 | IR-022, JA-030
+2740    async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
+2741        let endpoint = format!(
+2742            "/Users/{}/Items/{}",
+2743            self.user_id,
+2744            urlencoding::encode(person_id)
+2745        );
+2746        let item: JellyfinItem = self.get_json(&endpoint).await?;
+2747        Ok(item.into_media_item(self.user_id.clone()))
+2748    }
+2749
+2750    /// A person's filmography — every item they are credited on.
+2751    ///
+2752    /// TRACES: UR-036 | IR-022, JA-031
+2753    async fn get_items_by_person(
+2754        &self,
+2755        person_id: &str,
+2756        options: Option<GetItemsOptions>,
+2757    ) -> Result<SearchResult, RepoError> {
+2758        let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
+2759
+2760        let mut endpoint = format!(
+2761            "/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+2762            self.user_id, person_id, limit
+2763        );
+2764
+2765        // Add item type filtering if specified in options
+2766        if let Some(ref opts) = options {
+2767            if let Some(ref include_types) = opts.include_item_types {
+2768                if !include_types.is_empty() {
+2769                    let types_param = include_types.join(",");
+2770                    endpoint.push_str(&format!("&IncludeItemTypes={}", types_param));
+2771                }
+2772            }
+2773        }
+2774
+2775        let response: ItemsResponse = self.get_json(&endpoint).await?;
+2776        Ok(SearchResult {
+2777            items: response
+2778                .items
+2779                .into_iter()
+2780                .map(|item| item.into_media_item(self.user_id.clone()))
+2781                .collect(),
+2782            total_record_count: response.total_record_count,
+2783        })
+2784    }
+2785
+2786    async fn get_similar_items(
+2787        &self,
+2788        item_id: &str,
+2789        limit: Option<usize>,
+2790    ) -> Result<SearchResult, RepoError> {
+2791        let limit_str = limit.unwrap_or(20);
+2792
+2793        // Try the /Similar endpoint which works for most items
+2794        let endpoint = format!(
+2795            "/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+2796            item_id, self.user_id, limit_str
+2797        );
+2798
+2799        let response: ItemsResponse = self.get_json(&endpoint).await?;
+2800        Ok(SearchResult {
+2801            items: response
+2802                .items
+2803                .into_iter()
+2804                .map(|item| item.into_media_item(self.user_id.clone()))
+2805                .collect(),
+2806            total_record_count: response.total_record_count,
+2807        })
+2808    }
+2809
+2810    // ===== Playlist Methods =====
+2811
+2812    async fn create_playlist(
+2813        &self,
+2814        name: &str,
+2815        item_ids: &[String],
+2816    ) -> Result<PlaylistCreatedResult, RepoError> {
+2817        info!(
+2818            "[OnlineRepo] Creating playlist '{}' with {} items",
+2819            name,
+2820            item_ids.len()
+2821        );
+2822        let body = serde_json::json!({
+2823            "Name": name,
+2824            "Ids": item_ids,
+2825            "MediaType": "Audio",
+2826            "UserId": self.user_id,
+2827        });
+2828        let response: CreatePlaylistResponse = self.post_json_response("/Playlists", &body).await?;
+2829        Ok(PlaylistCreatedResult { id: response.id })
+2830    }
+2831
+2832    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
+2833        info!("[OnlineRepo] Deleting playlist {}", playlist_id);
+2834        let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
+2835        let url = format!("{}{}", self.server_url, endpoint);
+2836
+2837        let request = self
+2838            .http_client
+2839            .client
+2840            .delete(&url)
+2841            .header("X-Emby-Authorization", self.auth_header())
+2842            .build()
+2843            .map_err(|e| RepoError::Network {
+2844                message: format!("Failed to build request: {}", e),
+2845            })?;
+2846
+2847        let response = self
+2848            .http_client
+2849            .request_with_retry(request)
+2850            .await
+2851            .map_err(|e| RepoError::Network {
+2852                message: e.to_string(),
+2853            })?;
+2854
+2855        if !response.status().is_success() {
+2856            return Err(RepoError::Server {
+2857                message: format!("HTTP {}", response.status()),
+2858            });
+2859        }
+2860
+2861        Ok(())
+2862    }
+2863
+2864    async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
+2865        info!(
+2866            "[OnlineRepo] Renaming playlist {} to '{}'",
+2867            playlist_id, name
+2868        );
+2869        let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
+2870        self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
+2871            .await
+2872    }
+2873
+2874    async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
+2875        let endpoint = format!(
+2876            "/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
+2877            playlist_id, self.user_id
+2878        );
+2879
+2880        let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
+2881        debug!(
+2882            "[OnlineRepo] Got {} playlist items for {}",
+2883            response.items.len(),
+2884            playlist_id
+2885        );
+2886
+2887        Ok(response
+2888            .items
+2889            .into_iter()
+2890            .map(|pi| PlaylistEntry {
+2891                playlist_item_id: pi.playlist_item_id,
+2892                item: pi.item.into_media_item(self.user_id.clone()),
+2893            })
+2894            .collect())
+2895    }
+2896
+2897    async fn add_to_playlist(
+2898        &self,
+2899        playlist_id: &str,
+2900        item_ids: &[String],
+2901    ) -> Result<(), RepoError> {
+2902        info!(
+2903            "[OnlineRepo] Adding {} items to playlist {}",
+2904            item_ids.len(),
+2905            playlist_id
+2906        );
+2907        // Encode each id, not the joined string: the comma separates the list.
+2908        let ids_param = item_ids
+2909            .iter()
+2910            .map(|id| urlencoding::encode(id).into_owned())
+2911            .collect::<Vec<_>>()
+2912            .join(",");
+2913        let endpoint = format!(
+2914            "/Playlists/{}/Items?Ids={}",
+2915            urlencoding::encode(playlist_id),
+2916            ids_param
+2917        );
+2918        self.post_json(&endpoint, &serde_json::json!({})).await
+2919    }
+2920
+2921    async fn remove_from_playlist(
+2922        &self,
+2923        playlist_id: &str,
+2924        entry_ids: &[String],
+2925    ) -> Result<(), RepoError> {
+2926        info!(
+2927            "[OnlineRepo] Removing {} entries from playlist {}",
+2928            entry_ids.len(),
+2929            playlist_id
+2930        );
+2931        let ids_param = entry_ids
+2932            .iter()
+2933            .map(|id| urlencoding::encode(id).into_owned())
+2934            .collect::<Vec<_>>()
+2935            .join(",");
+2936        let endpoint = format!(
+2937            "/Playlists/{}/Items?EntryIds={}",
+2938            urlencoding::encode(playlist_id),
+2939            ids_param
+2940        );
+2941        let url = format!("{}{}", self.server_url, endpoint);
+2942
+2943        let request = self
+2944            .http_client
+2945            .client
+2946            .delete(&url)
+2947            .header("X-Emby-Authorization", self.auth_header())
+2948            .build()
+2949            .map_err(|e| RepoError::Network {
+2950                message: format!("Failed to build request: {}", e),
+2951            })?;
+2952
+2953        let response = self
+2954            .http_client
+2955            .request_with_retry(request)
+2956            .await
+2957            .map_err(|e| RepoError::Network {
+2958                message: e.to_string(),
+2959            })?;
+2960
+2961        if !response.status().is_success() {
+2962            return Err(RepoError::Server {
+2963                message: format!("HTTP {}", response.status()),
+2964            });
+2965        }
+2966
+2967        Ok(())
+2968    }
+2969
+2970    async fn move_playlist_item(
+2971        &self,
+2972        playlist_id: &str,
+2973        item_id: &str,
+2974        new_index: u32,
+2975    ) -> Result<(), RepoError> {
+2976        info!(
+2977            "[OnlineRepo] Moving item {} in playlist {} to index {}",
+2978            item_id, playlist_id, new_index
+2979        );
+2980        let endpoint = format!(
+2981            "/Playlists/{}/Items/{}/Move/{}",
+2982            playlist_id, item_id, new_index
+2983        );
+2984        self.post_json(&endpoint, &serde_json::json!({})).await
+2985    }
+2986}
+2987
+2988#[cfg(test)]
+2989mod tests {
+2990    use super::*;
+2991    use crate::utils::lock::MutexSafe;
+2992    use std::sync::Arc;
+2993
+2994    fn create_test_repository() -> OnlineRepository {
+2995        let http_config = crate::jellyfin::HttpConfig::default();
+2996        let http_client =
+2997            Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
+2998        OnlineRepository::new(
+2999            http_client,
+3000            "https://test.server.com".to_string(),
+3001            "test-user-id".to_string(),
+3002            "test-access-token".to_string(),
+3003        )
+3004    }
+3005
+3006    /// Build a repository wired to a real ConnectivityReporter so we can assert
+3007    /// how `report_outcome` classifies each `RepoError` into reachability.
+3008    /// (No app handle → event emission is a harmless no-op.)
+3009    fn create_test_repository_with_connectivity(
+3010    ) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
+3011        let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
+3012            .expect("Failed to create HTTP client for monitor");
+3013        let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
+3014        let reporter = monitor.reporter();
+3015        let repo = create_test_repository().with_connectivity(reporter.clone());
+3016        (repo, reporter)
+3017    }
+3018
+3019    /// `report_outcome` is the seam between repository traffic and the
+3020    /// connectivity monitor. Verify each `RepoError` variant routes correctly:
+3021    /// - the server answering at all (Ok / 401 / 404 / 5xx) ⇒ reachable
+3022    /// - a network-level failure ⇒ marked unreachable (debounce reduced for test)
+3023    /// - local-side errors (Database / Offline) ⇒ no effect on reachability
+3024    ///
+3025    /// @req-test: UR-002 - Access media when online or offline
+3026    /// @req-test: DR-013 - Repository pattern for online/offline data access
+3027    #[tokio::test]
+3028    async fn test_report_outcome_classifies_server_answered_as_reachable() {
+3029        let (repo, reporter) = create_test_repository_with_connectivity();
+3030
+3031        // Drive offline first so we can observe "recover to reachable".
+3032        for err in [
+3033            RepoError::Authentication {
+3034                message: "401".into(),
+3035            },
+3036            RepoError::NotFound {
+3037                message: "404".into(),
+3038            },
+3039            RepoError::Server {
+3040                message: "500".into(),
+3041            },
+3042        ] {
+3043            reporter.mark_unreachable_for_test().await;
+3044            assert!(!reporter.is_reachable().await, "precondition: offline");
+3045
+3046            let result: Result<(), RepoError> = Err(err);
+3047            repo.report_outcome(&result).await;
+3048
+3049            assert!(
+3050                reporter.is_reachable().await,
+3051                "a server that answers should be reported reachable"
+3052            );
+3053        }
+3054
+3055        // Ok should also report reachable.
+3056        reporter.mark_unreachable_for_test().await;
+3057        let ok: Result<(), RepoError> = Ok(());
+3058        repo.report_outcome(&ok).await;
+3059        assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
+3060    }
+3061
+3062    /// Local-side errors must NOT flip reachability — they say nothing about the
+3063    /// server.
+3064    #[tokio::test]
+3065    async fn test_report_outcome_ignores_local_errors() {
+3066        let (repo, reporter) = create_test_repository_with_connectivity();
+3067
+3068        // Force offline, then a Database/Offline error must leave it offline
+3069        // (not falsely report reachable).
+3070        reporter.mark_unreachable_for_test().await;
+3071        for err in [
+3072            RepoError::Database {
+3073                message: "cache".into(),
+3074            },
+3075            RepoError::Offline,
+3076        ] {
+3077            let result: Result<(), RepoError> = Err(err);
+3078            repo.report_outcome(&result).await;
+3079            assert!(
+3080                !reporter.is_reachable().await,
+3081                "local-side error must not change reachability"
+3082            );
+3083        }
+3084    }
+3085
+3086    /// When connectivity is known-offline, `get_json` must fast-fail with
+3087    /// `RepoError::Offline` instead of running the full HTTP retry cycle (~7s).
+3088    /// This is what keeps offline browsing snappy. `test.server.com` is
+3089    /// unroutable, so if the guard were absent this would hang on retries; the
+3090    /// assertion returning promptly with `Offline` proves the short-circuit.
+3091    #[tokio::test]
+3092    async fn test_get_json_fast_fails_when_offline() {
+3093        let (repo, reporter) = create_test_repository_with_connectivity();
+3094        reporter.mark_unreachable_for_test().await;
+3095        assert!(!reporter.is_reachable().await, "precondition: offline");
+3096
+3097        let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
+3098        assert!(
+3099            matches!(result, Err(RepoError::Offline)),
+3100            "known-offline get_json should return Offline immediately, got {:?}",
+3101            result
+3102        );
+3103    }
+3104
+3105    /// A network error routes through the debounced path. A single failure stays
+3106    /// online (debounce window not yet elapsed).
+3107    #[tokio::test]
+3108    async fn test_report_outcome_network_error_is_debounced() {
+3109        let (repo, reporter) = create_test_repository_with_connectivity();
+3110        assert!(reporter.is_reachable().await, "starts online");
+3111
+3112        let result: Result<(), RepoError> = Err(RepoError::Network {
+3113            message: "timeout".into(),
+3114        });
+3115        repo.report_outcome(&result).await;
+3116
+3117        assert!(
+3118            reporter.is_reachable().await,
+3119            "a single network failure stays online (debounced)"
+3120        );
+3121    }
+3122
+3123    #[tokio::test]
+3124    async fn test_get_audio_stream_url_formats_correctly() {
+3125        let repo = create_test_repository();
+3126        let item_id = "test-track-123";
+3127
+3128        let result = repo.get_audio_stream_url(item_id).await;
+3129
+3130        assert!(result.is_ok());
+3131        let url = result.unwrap();
+3132        assert_eq!(
+3133            url,
+3134            "https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&api_key=test-access-token&Static=true"
+3135        );
+3136    }
+3137
+3138    /// Serialises every test whose expectations depend on the process-wide
+3139    /// streaming ceiling, and restores the uncapped default afterwards — without
+3140    /// it, a capped test running concurrently changes what an uncapped one sees.
+3141    ///
+3142    /// TRACES: UR-074 | DR-162
+3143    static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+3144
+3145    struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
+3146
+3147    impl QualityFixture {
+3148        fn set(quality: StreamingQuality) -> Self {
+3149            let guard = QUALITY_LOCK.lock_safe();
+3150            set_streaming_quality(quality);
+3151            Self(guard)
+3152        }
+3153    }
+3154
+3155    impl Drop for QualityFixture {
+3156        fn drop(&mut self) {
+3157            set_streaming_quality(StreamingQuality::Original);
+3158            // A leaked per-playback override would cap every later test's
+3159            // expectations without appearing anywhere in its setup.
+3160            // TRACES: UR-074, UR-079 | DR-226
+3161            clear_playback_quality_override();
+3162        }
+3163    }
+3164
+3165    /// A cap has to reach the transcode URL as all four of its parts: the total
+3166    /// ceiling, the split between video and audio, and the resolution the budget
+3167    /// can carry. Capping only `MaxStreamingBitrate` would leave the server
+3168    /// encoding 1080p into 2 Mbps.
+3169    ///
+3170    /// TRACES: UR-074 | DR-162 | UT-156
+3171    #[tokio::test]
+3172    async fn test_video_stream_url_applies_bitrate_cap() {
+3173        let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
+3174        let repo = create_test_repository();
+3175
+3176        let url = repo
+3177            .get_video_stream_url("vid-1", None, None)
+3178            .await
+3179            .unwrap();
+3180
+3181        assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
+3182        // 2 Mbps total less the 192 kbps audio share — the two must not sum to
+3183        // more than the cap the user asked for.
+3184        assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
+3185        assert!(url.contains("AudioBitrate=192000"), "url: {url}");
+3186        assert!(url.contains("MaxHeight=720"), "url: {url}");
+3187    }
+3188
+3189    /// The uncapped default must keep the exact transcode allowance this
+3190    /// endpoint has always used, and must not start constraining resolution.
+3191    ///
+3192    /// TRACES: UR-074 | DR-162 | UT-156
+3193    #[tokio::test]
+3194    async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
+3195        let _fixture = QualityFixture::set(StreamingQuality::Original);
+3196        let repo = create_test_repository();
+3197
+3198        let url = repo
+3199            .get_video_stream_url("vid-1", None, None)
+3200            .await
+3201            .unwrap();
+3202
+3203        assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}");
+3204        assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
+3205        assert!(url.contains("AudioBitrate=384000"), "url: {url}");
+3206        assert!(
+3207            !url.contains("MaxHeight"),
+3208            "uncapped must not scale the picture down: {url}"
+3209        );
+3210    }
+3211
+3212    /// The background-audio handoff is already cheap, but someone who capped the
+3213    /// connection at 720 kbps asked for less traffic than its fixed 384 kbps.
+3214    ///
+3215    /// TRACES: UR-040, UR-074 | DR-162 | UT-156
+3216    #[tokio::test]
+3217    async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
+3218        {
+3219            let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
+3220            let repo = create_test_repository();
+3221            let url = repo
+3222                .get_audio_only_stream_url_for_video("vid-1", None, None, None)
+3223                .await
+3224                .unwrap();
+3225            assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
+3226        }
+3227
+3228        let _fixture = QualityFixture::set(StreamingQuality::Original);
+3229        let repo = create_test_repository();
+3230        let url = repo
+3231            .get_audio_only_stream_url_for_video("vid-1", None, None, None)
+3232            .await
+3233            .unwrap();
+3234        assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
+3235    }
+3236
+3237    /// Transcoded video must be an HLS master playlist, not a progressive
+3238    /// `stream.mp4`: a progressive transcode of an HEVC source makes the server
+3239    /// convert the whole file before serving a byte, which presents as playback
+3240    /// that never starts. The chosen source and audio track ride along with it.
+3241    ///
+3242    /// This is the surviving half of the old
+3243    /// `test_get_video_stream_url_returns_hls_with_position`, whose other half
+3244    /// asserted the `StartTimeTicks` that DR-181 removed — the position now
+3245    /// belongs to a seek after load, never to this URL, so the assertion for it
+3246    /// is gone rather than inverted (its inverse is UT-182's own test).
+3247    ///
+3248    /// TRACES: UR-004 | DR-140, DR-181 | UT-130
+3249    #[tokio::test]
+3250    async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
+3251        let _fixture = QualityFixture::set(StreamingQuality::Original);
+3252        let repo = create_test_repository();
+3253
+3254        let url = repo
+3255            .get_video_stream_url("vid-1", Some("source-1"), Some(1))
+3256            .await
+3257            .unwrap();
+3258
+3259        assert!(
+3260            url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
+3261            "expected HLS master playlist, got: {url}"
+3262        );
+3263        assert!(url.contains("VideoCodec=h264"));
+3264        assert!(url.contains("MediaSourceId=source-1"));
+3265        assert!(url.contains("AudioStreamIndex=1"));
+3266        assert!(!url.contains("stream.mp4"));
+3267    }
+3268
+3269    /// Resuming a transcoded video played nothing at all: every segment came back
+3270    /// `400`, hls.js exhausted its retries and gave up. Starting the same episode
+3271    /// from the beginning was fine.
+3272    ///
+3273    /// Jellyfin builds each segment URI by echoing the *master playlist's* query
+3274    /// string into it (`CreateMainPlaylistRequest(… Request.QueryString …)`), and
+3275    /// its segment handler opens with
+3276    ///
+3277    /// ```csharp
+3278    /// if ((streamingRequest.StartTimeTicks ?? 0) > 0)
+3279    ///     throw new ArgumentException("StartTimeTicks is not allowed.");
+3280    /// ```
+3281    ///
+3282    /// so a resume position put on the playlist is copied onto every
+3283    /// `hls1/main/N.ts` and makes all of them 400. `> 0` is exactly why playing
+3284    /// from the beginning survived.
+3285    ///
+3286    /// HLS does not need the parameter: the playlist spans the whole item, and
+3287    /// asking for segment N *is* the seek — the server transcodes from there. So
+3288    /// the position never belongs in this URL; the player seeks after load. The
+3289    /// sibling progressive `/Audio/universal` builder is a different endpoint with
+3290    /// no segments, and keeps its `StartTimeTicks`.
+3291    ///
+3292    /// TRACES: UR-004, UR-074 | DR-181 | UT-182
+3293    #[tokio::test]
+3294    async fn test_video_stream_url_never_carries_start_time_ticks() {
+3295        let _fixture = QualityFixture::set(StreamingQuality::Original);
+3296        let repo = create_test_repository();
+3297
+3298        let url = repo
+3299            .get_video_stream_url("vid-1", Some("source-1"), Some(1))
+3300            .await
+3301            .unwrap();
+3302
+3303        assert!(
+3304            !url.contains("StartTimeTicks"),
+3305            "an HLS playlist must never carry StartTimeTicks — the server copies it \
+3306             onto every segment URI and then rejects each one with 400: {url}"
+3307        );
+3308    }
+3309
+3310    #[tokio::test]
+3311    async fn test_get_video_stream_url_omits_position_when_absent() {
+3312        let _fixture = QualityFixture::set(StreamingQuality::Original);
+3313        let repo = create_test_repository();
+3314
+3315        let url = repo
+3316            .get_video_stream_url("vid-1", None, None)
+3317            .await
+3318            .unwrap();
+3319
+3320        assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
+3321        assert!(!url.contains("StartTimeTicks"));
+3322        assert!(!url.contains("MediaSourceId"));
+3323        // With no track chosen, the param must be OMITTED so the server picks the
+3324        // source's DefaultAudioStreamIndex. `MediaStream.Index` is global across
+3325        // all streams of a source, so index 0 is the *video* stream on virtually
+3326        // every file — sending it asks for a "audio track" that has no audio.
+3327        assert!(
+3328            !url.contains("AudioStreamIndex"),
+3329            "must not pin an audio index when none was chosen: {url}"
+3330        );
+3331    }
+3332
+3333    /// Jellyfin keys a transcode job by device *and* play session. Every stream
+3334    /// this app opened used the same `DeviceId` and no `PlaySessionId`, so
+3335    /// re-opening the same item — what a mid-playback quality switch, a
+3336    /// transcoded seek and an audio-track switch all do — handed the server a
+3337    /// second job it could not tell apart from the one still running. Observed
+3338    /// on-device: the new playlist is served, then `hls1/main/0.ts` 400s
+3339    /// intermittently while the two jobs fight over the same transcode path, and
+3340    /// playback stalls.
+3341    ///
+3342    /// TRACES: UR-074 | DR-177 | UT-173
+3343    #[tokio::test]
+3344    async fn test_video_stream_url_carries_a_play_session_id() {
+3345        let _fixture = QualityFixture::set(StreamingQuality::Original);
+3346        let repo = create_test_repository();
+3347
+3348        let url = repo
+3349            .get_video_stream_url("vid-1", None, None)
+3350            .await
+3351            .unwrap();
+3352
+3353        assert!(
+3354            url.contains("PlaySessionId="),
+3355            "every transcode must be openable as its own job: {url}"
+3356        );
+3357    }
+3358
+3359    /// Naming no subtitle stream is not the same as asking for none. The server
+3360    /// fills the gap with the source's own default/forced track, and an
+3361    /// image-based one (PGS/DVD/DVB) can only be delivered by painting it into
+3362    /// the picture — the burn-in of DR-176, arriving through the URL rather than
+3363    /// through the negotiation.
+3364    ///
+3365    /// The negotiation already sends the sentinel, but it is not what opens most
+3366    /// streams: a quality switch, a transcoded seek and an audio-track switch all
+3367    /// build this URL again, on their own. Saying it here too makes "no subtitle"
+3368    /// a property of the request instead of something inherited from whatever
+3369    /// session state the server happens to still hold.
+3370    ///
+3371    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
+3372    #[tokio::test]
+3373    async fn test_video_stream_url_asks_for_no_subtitle_stream() {
+3374        let _fixture = QualityFixture::set(StreamingQuality::Original);
+3375        let repo = create_test_repository();
+3376
+3377        let url = repo
+3378            .get_video_stream_url("vid-1", Some("source-1"), Some(1))
+3379            .await
+3380            .unwrap();
+3381
+3382        assert!(
+3383            url.contains("SubtitleStreamIndex=-1"),
+3384            "the stream URL must ask for no subtitle, not leave the choice open: {url}"
+3385        );
+3386    }
+3387
+3388    /// The picker must not offer a subtitle the app cannot draw. Image-based
+3389    /// tracks are bitmaps: the only way to show one is to have the server
+3390    /// composite it, which is exactly what DR-176 stopped asking for. Selecting
+3391    /// one was therefore a control that could not do anything — so the verdict
+3392    /// travels with the stream, decided here where the codec vocabulary lives.
+3393    ///
+3394    /// TRACES: UR-020 | DR-176 | UT-168
+3395    #[test]
+3396    fn test_media_streams_carry_whether_the_app_can_render_them() {
+3397        let item: JellyfinItem = serde_json::from_value(serde_json::json!({
+3398            "Id": "ep-1",
+3399            "Name": "Partings",
+3400            "Type": "Episode",
+3401            "MediaStreams": [
+3402                { "Type": "Video", "Index": 0, "Codec": "hevc", "IsDefault": true },
+3403                { "Type": "Audio", "Index": 1, "Codec": "eac3", "IsDefault": true },
+3404                { "Type": "Subtitle", "Index": 2, "Codec": "PGSSUB", "IsDefault": true },
+3405                { "Type": "Subtitle", "Index": 3, "Codec": "subrip", "IsDefault": false },
+3406                { "Type": "Subtitle", "Index": 4, "Codec": null, "IsDefault": false },
+3407            ],
+3408        }))
+3409        .expect("fixture must deserialize");
+3410
+3411        let streams = item.into_media_item("server-1".to_string()).media_streams;
+3412        let streams = streams.expect("the item carries streams");
+3413        let deliverable = |index: i32| {
+3414            streams
+3415                .iter()
+3416                .find(|s| s.index == index)
+3417                .unwrap_or_else(|| panic!("stream {index} missing"))
+3418                .supports_external_delivery
+3419        };
+3420
+3421        // The bitmap track the server would have had to burn in.
+3422        assert_eq!(deliverable(2), Some(false));
+3423        // Text: fetched as WebVTT and drawn by the app itself.
+3424        assert_eq!(deliverable(3), Some(true));
+3425        // A subtitle whose format the server did not name could be anything;
+3426        // offering it risks a dead control, so it is not offered.
+3427        assert_eq!(deliverable(4), Some(false));
+3428        // Meaningless for anything that is not a subtitle — and said as `None`
+3429        // rather than as a `false` a reader could mistake for a verdict.
+3430        assert_eq!(deliverable(0), None);
+3431        assert_eq!(deliverable(1), None);
+3432    }
+3433
+3434    /// The session id is what makes two opens *distinguishable*, so a fresh one
+3435    /// per open is the whole point — and the open must report the id it replaced
+3436    /// so the caller can stop that job instead of leaving it running.
+3437    ///
+3438    /// TRACES: UR-074 | DR-177 | UT-173
+3439    #[test]
+3440    fn test_each_stream_open_gets_a_fresh_session_and_reports_the_previous() {
+3441        let _lock = QUALITY_LOCK.lock_safe();
+3442
+3443        let (first, _) = begin_video_play_session();
+3444        let (second, replaced) = begin_video_play_session();
+3445
+3446        assert_ne!(first, second, "each open needs its own job identity");
+3447        assert_eq!(
+3448            replaced,
+3449            Some(first),
+3450            "the open must hand back the job it superseded so it can be stopped"
+3451        );
+3452
+3453        // A server-started transcode (PlaybackInfo answered with a TranscodingUrl)
+3454        // has to become the current session too — otherwise the first switch on
+3455        // that stream stops nothing and collides with what is playing.
+3456        let replaced_by_adoption = adopt_video_play_session("server-named-session".to_string());
+3457        assert_eq!(replaced_by_adoption, Some(second));
+3458
+3459        let (_, after_adoption) = begin_video_play_session();
+3460        assert_eq!(
+3461            after_adoption,
+3462            Some("server-named-session".to_string()),
+3463            "the adopted job must be the one the next open stops"
+3464        );
+3465    }
+3466
+3467    #[tokio::test]
+3468    async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
+3469        // TRACES: UR-040 | JA-032 | UT-059
+3470        // Background-audio handoff must request an audio-only stream (no video
+3471        // decode) that resumes at the current position and keeps the selected
+3472        // audio track.
+3473        let repo = create_test_repository();
+3474
+3475        let url = repo
+3476            .get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
+3477            .await
+3478            .unwrap();
+3479
+3480        assert!(
+3481            url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
+3482            "expected audio-only universal endpoint, got: {url}"
+3483        );
+3484        // Must NOT be a video stream (no client video decode in background).
+3485        assert!(
+3486            !url.contains("/Videos/"),
+3487            "url must not hit the video endpoint: {url}"
+3488        );
+3489        assert!(
+3490            !url.contains("master.m3u8"),
+3491            "url must not be a video HLS playlist: {url}"
+3492        );
+3493        assert!(url.contains("AudioStreamIndex=2"));
+3494        assert!(url.contains("MediaSourceId=source-1"));
+3495        // 193.0 seconds * 10_000_000 ticks/sec
+3496        assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
+3497        // Progressive mp3 over HTTP — NOT HLS/ts, or ExoPlayer's progressive
+3498        // loader fails with ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED.
+3499        assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
+3500        assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
+3501        assert!(
+3502            !url.contains("TranscodingProtocol=hls"),
+3503            "url must not be HLS: {url}"
+3504        );
+3505        assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
+3506    }
+3507
+3508    #[tokio::test]
+3509    async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
+3510        // TRACES: UR-040 | JA-032 | UT-059
+3511        let repo = create_test_repository();
+3512
+3513        let url = repo
+3514            .get_audio_only_stream_url_for_video("vid-1", None, None, None)
+3515            .await
+3516            .unwrap();
+3517
+3518        assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
+3519        assert!(!url.contains("StartTimeTicks"));
+3520        assert!(!url.contains("MediaSourceId"));
+3521        // Same as the video path: omit rather than pin index 0 (the video stream),
+3522        // and let the server fall back to the source's default audio stream.
+3523        assert!(
+3524            !url.contains("AudioStreamIndex"),
+3525            "must not pin an audio index when none was chosen: {url}"
+3526        );
+3527    }
+3528
+3529    #[tokio::test]
+3530    async fn test_get_audio_stream_url_with_special_characters() {
+3531        let repo = create_test_repository();
+3532        let item_id = "track-with-special-chars-!@#";
+3533
+3534        let result = repo.get_audio_stream_url(item_id).await;
+3535
+3536        assert!(result.is_ok());
+3537        let url = result.unwrap();
+3538        assert!(url.contains("track-with-special-chars-!@#"));
+3539        assert!(url.starts_with("https://test.server.com/Audio/"));
+3540    }
+3541
+3542    #[test]
+3543    fn test_image_tags_deserialize_hashmap_format() {
+3544        // Test modern HashMap format with Primary tag
+3545        let json = r#"{"Primary":"abc123","Banner":"def456","Backdrop":"ghi789"}"#;
+3546        let result: Result<ImageTags, _> = serde_json::from_str(json);
+3547
+3548        assert!(result.is_ok());
+3549        let tags = result.unwrap();
+3550        assert_eq!(tags.primary(), Some("abc123".to_string()));
+3551    }
+3552
+3553    #[test]
+3554    fn test_image_tags_deserialize_structured_format() {
+3555        // Test legacy structured format with Primary field
+3556        let json = r#"{"Primary":"xyz789"}"#;
+3557        let result: Result<ImageTags, _> = serde_json::from_str(json);
+3558
+3559        assert!(result.is_ok());
+3560        let tags = result.unwrap();
+3561        assert_eq!(tags.primary(), Some("xyz789".to_string()));
+3562    }
+3563
+3564    #[test]
+3565    fn test_image_tags_deserialize_missing_primary() {
+3566        // Test HashMap without Primary tag
+3567        let json = r#"{"Banner":"def456","Backdrop":"ghi789"}"#;
+3568        let result: Result<ImageTags, _> = serde_json::from_str(json);
+3569
+3570        assert!(result.is_ok());
+3571        let tags = result.unwrap();
+3572        assert_eq!(tags.primary(), None);
+3573    }
+3574
+3575    #[test]
+3576    fn test_image_tags_deserialize_empty_map() {
+3577        // Test empty HashMap
+3578        let json = r#"{}"#;
+3579        let result: Result<ImageTags, _> = serde_json::from_str(json);
+3580
+3581        assert!(result.is_ok());
+3582        let tags = result.unwrap();
+3583        assert_eq!(tags.primary(), None);
+3584    }
+3585
+3586    // ===== Video download URL (real impl) =====
+3587    //
+3588    // These exercise the PRODUCTION `OnlineRepository::get_video_download_url`,
+3589    // not a mock. A prior mock in online_integration_test.rs used the correct
+3590    // `stream.mp4` endpoint while the real impl shipped `/Videos/{id}/download`,
+3591    // which returns 404 on real servers and silently broke every movie/TV
+3592    // download. Assert the real builder targets the resumable stream endpoint.
+3593    //
+3594    // @req-test: DR-013 - Repository pattern for online/offline data access
+3595
+3596    #[test]
+3597    fn test_video_download_url_uses_stream_not_download_endpoint() {
+3598        let repo = create_test_repository();
+3599        let url = repo.get_video_download_url("item123", "original", None, None);
+3600
+3601        // Must NOT use the /download endpoint (404 on real servers).
+3602        assert!(
+3603            !url.contains("/download"),
+3604            "download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
+3605        );
+3606        // Must use the progressive, range-resumable stream endpoint.
+3607        assert!(
+3608            url.contains("/Videos/item123/stream.mp4"),
+3609            "download URL must target /Videos/{{id}}/stream.mp4: {url}"
+3610        );
+3611        assert!(url.contains("api_key=test-access-token"), "url: {url}");
+3612    }
+3613
+3614    #[test]
+3615    fn test_video_download_url_original_is_static_direct_copy() {
+3616        let repo = create_test_repository();
+3617        let url = repo.get_video_download_url("item123", "original", None, None);
+3618
+3619        // "original" must request a direct static copy (byte-range resumable),
+3620        // with no transcode params.
+3621        assert!(url.contains("Static=true"), "url: {url}");
+3622        assert!(
+3623            !url.contains("videoBitRate"),
+3624            "original must not transcode: {url}"
+3625        );
+3626        assert!(
+3627            !url.contains("maxHeight"),
+3628            "original must not transcode: {url}"
+3629        );
+3630    }
+3631
+3632    #[test]
+3633    fn test_video_download_url_quality_presets_transcode() {
+3634        let repo = create_test_repository();
+3635
+3636        for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
+3637            let url = repo.get_video_download_url("item123", quality, None, None);
+3638            assert!(
+3639                url.contains("/Videos/item123/stream.mp4"),
+3640                "{quality} must use stream.mp4: {url}"
+3641            );
+3642            assert!(
+3643                url.contains("videoBitRate="),
+3644                "{quality} must set bitrate: {url}"
+3645            );
+3646            assert!(
+3647                url.contains(&format!("maxHeight={height}")),
+3648                "{quality} must cap height at {height}: {url}"
+3649            );
+3650            assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
+3651            // Transcoded presets must not also ask for a static copy.
+3652            assert!(
+3653                !url.contains("Static=true"),
+3654                "{quality} must not be Static: {url}"
+3655            );
+3656        }
+3657    }
+3658
+3659    /// The bitrate params are spelled `videoBitRate`/`audioBitRate` — **capital
+3660    /// R**. Jellyfin binds query keys case-insensitively, so this is not a
+3661    /// casing preference: `videoBitrate` is a *different token* that fails to
+3662    /// bind and is silently discarded, taking the user's quality cap with it.
+3663    /// Nothing errors — the download just returns the full-size original, which
+3664    /// is exactly how this bug went unnoticed.
+3665    #[test]
+3666    fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
+3667        let repo = create_test_repository();
+3668
+3669        for quality in ["high", "medium", "low"] {
+3670            let url = repo.get_video_download_url("item123", quality, None, None);
+3671
+3672            assert!(
+3673                url.contains("videoBitRate="),
+3674                "{quality} must spell it videoBitRate (capital R): {url}"
+3675            );
+3676            assert!(
+3677                url.contains("audioBitRate="),
+3678                "{quality} must spell it audioBitRate (capital R): {url}"
+3679            );
+3680
+3681            // The lowercase-r spellings never bind — they must not appear at
+3682            // all, or the cap is silently dropped by the server.
+3683            assert!(
+3684                !url.contains("videoBitrate="),
+3685                "{quality} emits the unbindable lowercase-r spelling: {url}"
+3686            );
+3687            assert!(
+3688                !url.contains("audioBitrate="),
+3689                "{quality} emits the unbindable lowercase-r spelling: {url}"
+3690            );
+3691        }
+3692    }
+3693
+3694    /// A correctly-spelled cap is still only *conditionally* honored: the server
+3695    /// may stream-copy the source when it already satisfies the cap. Video copy
+3696    /// is gated by `allowVideoStreamCopy` (NOT `enableAutoStreamCopy`, which
+3697    /// only governs audio), so the transcode presets must disable it to
+3698    /// guarantee a real re-encode at the requested bitrate.
+3699    #[test]
+3700    fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
+3701        let repo = create_test_repository();
+3702
+3703        for quality in ["high", "medium", "low"] {
+3704            let url = repo.get_video_download_url("item123", quality, None, None);
+3705            assert!(
+3706                url.contains("allowVideoStreamCopy=false"),
+3707                "{quality} must forbid video stream copy: {url}"
+3708            );
+3709        }
+3710
+3711        // "original" is a deliberate direct copy — it must NOT disable copying.
+3712        let original = repo.get_video_download_url("item123", "original", None, None);
+3713        assert!(
+3714            !original.contains("allowVideoStreamCopy=false"),
+3715            "original must remain a direct copy: {original}"
+3716        );
+3717    }
+3718
+3719    /// A downloaded file is played with no server in reach, so `original`
+3720    /// quality cannot mean "copy whatever the source holds" when the source
+3721    /// holds audio this device cannot decode.
+3722    ///
+3723    /// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
+3724    /// track included, and video plays through the webview `<video>` element on
+3725    /// both platforms — which decodes none of them. Streaming already knows this
+3726    /// (DR-149 forces a transcode over the server's own direct-play offer); the
+3727    /// download path did not, so a downloaded film played offline as picture with
+3728    /// no sound while the very same film had sound when streamed.
+3729    ///
+3730    /// TRACES: UR-071, UR-004 | DR-171 | UT-166
+3731    #[test]
+3732    fn test_video_download_url_original_transcodes_undecodable_audio() {
+3733        let repo = create_test_repository();
+3734
+3735        for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
+3736            let url = repo.get_video_download_url("item123", "original", None, Some(codec));
+3737            assert!(
+3738                !url.contains("Static=true"),
+3739                "{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
+3740            );
+3741            assert!(
+3742                url.contains("audioCodec=aac"),
+3743                "{codec} must be re-encoded to aac on the way down: {url}"
+3744            );
+3745            // "Original" still has to mean original picture: the video stream is
+3746            // copied when it can be, so no bitrate or resolution cap appears.
+3747            assert!(
+3748                url.contains("allowVideoStreamCopy=true"),
+3749                "the video stream must still be copied where possible: {url}"
+3750            );
+3751            assert!(
+3752                !url.contains("videoBitRate") && !url.contains("maxHeight"),
+3753                "original must not degrade the picture to fix the audio: {url}"
+3754            );
+3755        }
+3756    }
+3757
+3758    /// The converse, and the reason the policy is per-item rather than blanket:
+3759    /// audio that plays here keeps the byte-exact, range-resumable copy that the
+3760    /// download worker's resume depends on.
+3761    ///
+3762    /// TRACES: UR-071 | DR-171 | UT-166
+3763    #[test]
+3764    fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
+3765        let repo = create_test_repository();
+3766
+3767        for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
+3768            let url = repo.get_video_download_url("item123", "original", None, Some(codec));
+3769            assert!(
+3770                url.contains("Static=true"),
+3771                "{codec} plays here — the download must stay a direct copy: {url}"
+3772            );
+3773            assert!(
+3774                !url.contains("audioCodec="),
+3775                "{codec} needs no transcode: {url}"
+3776            );
+3777        }
+3778
+3779        // Unknown codec: the policy only ever *adds* a transcode, so an item we
+3780        // could not look up behaves exactly as it did before.
+3781        let unknown = repo.get_video_download_url("item123", "original", None, None);
+3782        assert!(unknown.contains("Static=true"), "url: {unknown}");
+3783    }
+3784
+3785    /// The explicit quality presets already transcode audio to AAC, so the
+3786    /// policy has nothing to add — and must not start overriding a chosen cap.
+3787    ///
+3788    /// TRACES: UR-071 | DR-171 | UT-166
+3789    #[test]
+3790    fn test_video_download_url_presets_ignore_the_audio_policy() {
+3791        let repo = create_test_repository();
+3792
+3793        for quality in ["high", "medium", "low"] {
+3794            let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
+3795            let without = repo.get_video_download_url("item123", quality, None, None);
+3796            assert_eq!(with, without, "{quality} must not vary with source audio");
+3797            assert!(with.contains("audioCodec=aac"), "url: {with}");
+3798        }
+3799    }
+3800
+3801    #[test]
+3802    fn test_video_download_url_passes_media_source_id() {
+3803        let repo = create_test_repository();
+3804        let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
+3805        assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
+3806    }
+3807
+3808    #[test]
+3809    fn test_jellyfin_item_deserialize_with_image_tags() {
+3810        // Test full JellyfinItem deserialization with ImageTags
+3811        let json = r#"{
+3812            "Id": "album123",
+3813            "Name": "Test Album",
+3814            "Type": "MusicAlbum",
+3815            "ImageTags": {"Primary": "tag123"},
+3816            "ArtistItems": [
+3817                {"Id": "artist1", "Name": "Artist One"},
+3818                {"Id": "artist2", "Name": "Artist Two"}
+3819            ]
+3820        }"#;
+3821
+3822        let result: Result<JellyfinItem, _> = serde_json::from_str(json);
+3823        assert!(result.is_ok());
+3824
+3825        let item = result.unwrap();
+3826        assert_eq!(item.id, "album123");
+3827        assert_eq!(item.name, "Test Album");
+3828        assert_eq!(item.item_type, "MusicAlbum");
+3829        assert!(item.image_tags.is_some());
+3830        assert_eq!(
+3831            item.image_tags.unwrap().primary(),
+3832            Some("tag123".to_string())
+3833        );
+3834    }
+3835
+3836    /// UT-100 — the favourites endpoint asks the server for favourites, scoped.
+3837    ///
+3838    /// TRACES: UR-067 | DR-115, JA-033 | UT-100
+3839    #[test]
+3840    fn test_build_favorites_endpoint_scopes_and_filters() {
+3841        let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
+3842        assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
+3843        assert!(movies.contains("&IncludeItemTypes=Movie"));
+3844        // Jellyfin has no favourite timestamp, so name order is the default.
+3845        assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
+3846        // Hearts must render on the returned cards.
+3847        assert!(movies.contains("UserData"));
+3848
+3849        // Tv covers both the show and any individually favourited episode.
+3850        let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
+3851        assert!(tv.contains("&IncludeItemTypes=Series,Episode"));
+3852
+3853        let music = build_favorites_endpoint("u1", SearchScope::Music, None);
+3854        assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
+3855    }
+3856
+3857    /// `All` must omit the type filter entirely rather than send a union, which
+3858    /// would silently drop every type nobody enumerated.
+3859    ///
+3860    /// TRACES: UR-067 | DR-115 | UT-100
+3861    #[test]
+3862    fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
+3863        let all = build_favorites_endpoint("u1", SearchScope::All, None);
+3864        assert!(!all.contains("IncludeItemTypes"));
+3865    }
+3866
+3867    /// Paging and an explicit sort still reach the server.
+3868    ///
+3869    /// TRACES: UR-067 | DR-115 | UT-100
+3870    #[test]
+3871    fn test_build_favorites_endpoint_honours_paging_and_sort() {
+3872        let endpoint = build_favorites_endpoint(
+3873            "u1",
+3874            SearchScope::All,
+3875            Some(&GetItemsOptions {
+3876                limit: Some(20),
+3877                start_index: Some(40),
+3878                sort_by: Some("Random".to_string()),
+3879                sort_order: Some("Descending".to_string()),
+3880                ..Default::default()
+3881            }),
+3882        );
+3883        assert!(endpoint.contains("&Limit=20"));
+3884        assert!(endpoint.contains("&StartIndex=40"));
+3885        assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
+3886    }
+3887
+3888    /// UT-104 — the in-library favourites toggle reaches the server as
+3889    /// `Filters=IsFavorite`, and is absent unless asked for.
+3890    ///
+3891    /// TRACES: UR-067 | DR-116 | UT-104
+3892    #[test]
+3893    fn test_get_items_endpoint_applies_favorites_only() {
+3894        let plain = build_get_items_endpoint("u1", "lib-1", None);
+3895        assert!(!plain.contains("Filters=IsFavorite"));
+3896
+3897        let filtered = build_get_items_endpoint(
+3898            "u1",
+3899            "lib-1",
+3900            Some(&GetItemsOptions {
+3901                favorites_only: Some(true),
+3902                include_item_types: Some(vec!["Movie".to_string()]),
+3903                ..Default::default()
+3904            }),
+3905        );
+3906        assert!(filtered.contains("&Filters=IsFavorite"));
+3907        // Composes with the filters already there rather than replacing them.
+3908        assert!(filtered.contains("&IncludeItemTypes=Movie"));
+3909        assert!(filtered.contains("ParentId=lib-1"));
+3910
+3911        // Explicitly false is not a request to filter.
+3912        let off = build_get_items_endpoint(
+3913            "u1",
+3914            "lib-1",
+3915            Some(&GetItemsOptions {
+3916                favorites_only: Some(false),
+3917                ..Default::default()
+3918            }),
+3919        );
+3920        assert!(!off.contains("Filters=IsFavorite"));
+3921    }
+3922
+3923    /// UT-206 — the values this endpoint builder puts in the query string are
+3924    /// percent-encoded, like `Genres` and `SearchTerm` already are.
+3925    ///
+3926    /// Unencoded, a value carrying `&` or `=` splits into an extra query
+3927    /// parameter (a parent id containing a space produced a malformed URL
+3928    /// outright), so the request the server sees is not the one that was built.
+3929    ///
+3930    /// TRACES: UR-007 | DR-212 | UT-206
+3931    #[test]
+3932    fn test_get_items_endpoint_encodes_query_values() {
+3933        let endpoint = build_get_items_endpoint(
+3934            "u1",
+3935            "lib 1&Filters=IsFavorite",
+3936            Some(&GetItemsOptions {
+3937                include_item_types: Some(vec!["Movie&x=1".to_string()]),
+3938                sort_by: Some("Sort Name".to_string()),
+3939                sort_order: Some("Ascending&y=2".to_string()),
+3940                ..Default::default()
+3941            }),
+3942        );
+3943        assert!(
+3944            endpoint.contains("ParentId=lib%201%26Filters%3DIsFavorite"),
+3945            "{endpoint}"
+3946        );
+3947        assert!(
+3948            endpoint.contains("&IncludeItemTypes=Movie%26x%3D1"),
+3949            "{endpoint}"
+3950        );
+3951        assert!(endpoint.contains("&SortBy=Sort%20Name"), "{endpoint}");
+3952        assert!(
+3953            endpoint.contains("&SortOrder=Ascending%26y%3D2"),
+3954            "{endpoint}"
+3955        );
+3956        // Nothing smuggled in as a parameter of its own.
+3957        assert!(!endpoint.contains("&Filters=IsFavorite"), "{endpoint}");
+3958        assert!(!endpoint.contains("&x=1"), "{endpoint}");
+3959        assert!(!endpoint.contains("&y=2"), "{endpoint}");
+3960    }
+3961
+3962    /// The separators inside a list parameter must survive encoding: Jellyfin
+3963    /// splits `SortBy` and `IncludeItemTypes` on commas, and `hybrid.rs` sends
+3964    /// "ParentIndexNumber,IndexNumber,SortName" to order episodes.
+3965    ///
+3966    /// TRACES: UR-007 | DR-212 | UT-206
+3967    #[test]
+3968    fn test_get_items_endpoint_keeps_list_separators() {
+3969        let endpoint = build_get_items_endpoint(
+3970            "u1",
+3971            "lib-1",
+3972            Some(&GetItemsOptions {
+3973                sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
+3974                include_item_types: Some(vec!["Movie".to_string(), "Series".to_string()]),
+3975                ..Default::default()
+3976            }),
+3977        );
+3978        assert!(
+3979            endpoint.contains("&SortBy=ParentIndexNumber,IndexNumber,SortName"),
+3980            "{endpoint}"
+3981        );
+3982        assert!(
+3983            endpoint.contains("&IncludeItemTypes=Movie,Series"),
+3984            "{endpoint}"
+3985        );
+3986        // A plain GUID parent id is unchanged by encoding.
+3987        assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
+3988    }
+3989
+3990    /// A newly-added album must arrive as one entry, not one per track.
+3991    ///
+3992    /// Jellyfin's `/Items/Latest` defaults to `GroupItems=false`, which returns
+3993    /// every new Audio track individually — so ripping a 14-track album filled
+3994    /// the whole "recently added" row with that one album. `GroupItems=true`
+3995    /// makes the server collapse children into their parent container.
+3996    #[test]
+3997    fn test_latest_items_endpoint_groups_children_into_containers() {
+3998        let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
+3999
+4000        assert!(
+4001            endpoint.contains("GroupItems=true"),
+4002            "latest items must be grouped so an album counts once, got: {}",
+4003            endpoint
+4004        );
+4005        assert!(endpoint.contains("ParentId=lib-1"));
+4006        assert!(endpoint.contains("Limit=16"));
+4007    }
+4008
+4009    /// UT-190 — Next Up asks the server to leave resumable episodes out.
+4010    ///
+4011    /// Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns
+4012    /// the *in-progress* episode as a series' next up — exactly the episode
+4013    /// `/Items/Resume` already returns, so Continue Watching and Next Up render
+4014    /// the same cards.
+4015    ///
+4016    /// TRACES: UR-059 | DR-197, JA-036 | UT-190
+4017    #[test]
+4018    fn test_build_next_up_endpoint_excludes_resumable() {
+4019        let endpoint = build_next_up_endpoint("u1", None, Some(12));
+4020
+4021        assert!(
+4022            endpoint.contains("EnableResumable=false"),
+4023            "next up must exclude in-progress episodes, got: {}",
+4024            endpoint
+4025        );
+4026        assert!(endpoint.contains("UserId=u1"));
+4027        assert!(endpoint.contains("Limit=12"));
+4028        assert!(
+4029            !endpoint.contains("SeriesId"),
+4030            "no series filter when none was requested, got: {}",
+4031            endpoint
+4032        );
+4033    }
+4034
+4035    /// UT-191 — a per-series Next Up query keeps the series filter.
+4036    ///
+4037    /// TRACES: UR-059 | DR-197 | UT-191
+4038    #[test]
+4039    fn test_build_next_up_endpoint_scopes_to_series() {
+4040        let endpoint = build_next_up_endpoint("u1", Some("series-a"), None);
+4041
+4042        assert!(endpoint.contains("SeriesId=series-a"));
+4043        assert!(endpoint.contains("EnableResumable=false"));
+4044        assert!(
+4045            endpoint.contains("Limit=16"),
+4046            "default limit, got: {}",
+4047            endpoint
+4048        );
+4049    }
+4050
+4051    /// UT-099 — a Jellyfin item's `UserData` reaches `MediaItem.user_data`.
+4052    ///
+4053    /// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
+4054    /// the mini player could know an item was favourited.
+4055    ///
+4056    /// TRACES: UR-069 | DR-113, JA-034 | UT-099
+4057    #[test]
+4058    fn test_jellyfin_item_maps_user_data_favorite() {
+4059        let json = r#"{
+4060            "Id": "movie123",
+4061            "Name": "Test Movie",
+4062            "Type": "Movie",
+4063            "UserData": {
+4064                "PlaybackPositionTicks": 6000000000,
+4065                "Played": false,
+4066                "IsFavorite": true,
+4067                "PlayCount": 2,
+4068                "LastPlayedDate": "2026-08-01T12:00:00Z"
+4069            }
+4070        }"#;
+4071
+4072        let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
+4073        let media = item.into_media_item("server1".to_string());
+4074
+4075        let user_data = media.user_data.expect("user data should be mapped");
+4076        assert_eq!(user_data.is_favorite, Some(true));
+4077        assert_eq!(user_data.is_played, Some(false));
+4078        assert_eq!(user_data.play_count, Some(2));
+4079        assert_eq!(user_data.playback_position_ticks, Some(6_000_000_000));
+4080        // Ticks are converted for the frontend, which never divides them itself.
+4081        assert_eq!(user_data.playback_position_ms, Some(600_000));
+4082    }
+4083
+4084    /// An item without `UserData` still maps — the field is optional, and every
+4085    /// non-user-scoped endpoint omits it.
+4086    ///
+4087    /// TRACES: UR-069 | DR-113 | UT-099
+4088    #[test]
+4089    fn test_jellyfin_item_without_user_data_maps_to_none() {
+4090        let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;
+4091
+4092        let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
+4093        let media = item.into_media_item("server1".to_string());
+4094
+4095        assert!(media.user_data.is_none());
+4096    }
+4097
+4098    #[test]
+4099    fn test_jellyfin_item_deserialize_with_artist_items() {
+4100        // Test that ArtistItems with PascalCase fields deserialize correctly
+4101        let json = r#"{
+4102            "Id": "track123",
+4103            "Name": "Test Track",
+4104            "Type": "Audio",
+4105            "ArtistItems": [
+4106                {"Id": "artist1", "Name": "Bob Dylan"},
+4107                {"Id": "artist2", "Name": "Johnny Cash"}
+4108            ]
+4109        }"#;
+4110
+4111        let result: Result<JellyfinItem, _> = serde_json::from_str(json);
+4112        assert!(result.is_ok());
+4113
+4114        let item = result.unwrap();
+4115        let artist_items = item.artist_items.expect("Expected artist items");
+4116        assert_eq!(artist_items.len(), 2);
+4117        assert_eq!(artist_items[0].id, "artist1");
+4118        assert_eq!(artist_items[0].name, "Bob Dylan");
+4119        assert_eq!(artist_items[1].id, "artist2");
+4120        assert_eq!(artist_items[1].name, "Johnny Cash");
+4121    }
+4122
+4123    #[test]
+4124    fn test_jellyfin_item_to_media_item_conversion() {
+4125        // Test conversion from JellyfinItem to MediaItem preserves image tags
+4126        let json = r#"{
+4127            "Id": "album456",
+4128            "Name": "Love and Theft",
+4129            "Type": "MusicAlbum",
+4130            "ImageTags": {"Primary": "7ebab4f6a80cd09d"},
+4131            "Artists": ["Bob Dylan"],
+4132            "ArtistItems": [{"Id": "0b2a6e969a27f22aba97f9f0e69fa849", "Name": "Bob Dylan"}],
+4133            "RunTimeTicks": 33900137190
+4134        }"#;
+4135
+4136        let jellyfin_item: JellyfinItem = serde_json::from_str(json).expect("Failed to parse");
+4137        let media_item = jellyfin_item.into_media_item("test-server-id".to_string());
+4138
+4139        assert_eq!(media_item.id, "album456");
+4140        assert_eq!(media_item.name, "Love and Theft");
+4141        assert_eq!(media_item.item_type, "MusicAlbum");
+4142        assert_eq!(
+4143            media_item.primary_image_tag,
+4144            Some("7ebab4f6a80cd09d".to_string())
+4145        );
+4146        assert_eq!(media_item.server_id, "test-server-id");
+4147    }
+4148
+4149    #[test]
+4150    fn test_items_response_deserialize() {
+4151        // Test full ItemsResponse with multiple items
+4152        let json = r#"{
+4153            "Items": [
+4154                {
+4155                    "Id": "item1",
+4156                    "Name": "Item One",
+4157                    "Type": "MusicAlbum",
+4158                    "ImageTags": {"Primary": "tag1"}
+4159                },
+4160                {
+4161                    "Id": "item2",
+4162                    "Name": "Item Two",
+4163                    "Type": "Audio",
+4164                    "ImageTags": {"Primary": "tag2"}
+4165                }
+4166            ],
+4167            "TotalRecordCount": 2
+4168        }"#;
+4169
+4170        let result: Result<ItemsResponse, _> = serde_json::from_str(json);
+4171        assert!(result.is_ok());
+4172
+4173        let response = result.unwrap();
+4174        assert_eq!(response.total_record_count, 2);
+4175        assert_eq!(response.items.len(), 2);
+4176        assert_eq!(response.items[0].id, "item1");
+4177        assert_eq!(response.items[1].id, "item2");
+4178    }
+4179
+4180    #[test]
+4181    fn test_search_term_is_url_encoded() {
+4182        // A multi-word query (and one with a reserved character) must be
+4183        // percent-encoded before being placed in the SearchTerm query param,
+4184        // otherwise the request URL is malformed and search returns nothing.
+4185        assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
+4186        assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
+4187    }
+4188
+4189    #[test]
+4190    fn test_jray_context_deserializes_actors() {
+4191        // The jray?t= envelope as documented in the JRay truth file format.
+4192        let json = r#"{
+4193            "actors": [
+4194                { "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
+4195            ]
+4196        }"#;
+4197        let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
+4198        assert_eq!(ctx.actors.len(), 1);
+4199        assert_eq!(ctx.actors[0].name, "Tom Hanks");
+4200        assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
+4201    }
+4202
+4203    #[test]
+4204    fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
+4205        // Future fields (locations/trivia) must be ignored, and absent id keys
+4206        // must default to "" rather than failing to parse.
+4207        let json = r#"{
+4208            "actors": [ { "name": "Extra" } ],
+4209            "locations": ["Beach"],
+4210            "trivia": "filmed in 1994"
+4211        }"#;
+4212        let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
+4213        assert_eq!(ctx.actors.len(), 1);
+4214        assert_eq!(ctx.actors[0].name, "Extra");
+4215        assert_eq!(ctx.actors[0].imdb_id, "");
+4216        assert_eq!(ctx.actors[0].jellyfin_id, "");
+4217    }
+4218
+4219    // -----------------------------------------------------------------------
+4220    // Direct-play negotiation (DR-228)
+4221    //
+4222    // Fixtures rather than a live server, but the *shapes* are real: every one
+4223    // below was observed in a `PlaybackInfo` response from the development
+4224    // server while this was written. The measured yields those shapes produce —
+4225    // 7% direct play under the Linux h264-only profile, 85% under the Android
+4226    // profile — are recorded in the spec, not asserted here; what is asserted is
+4227    // that each shape maps to the kind it should.
+4228    // -----------------------------------------------------------------------
+4229
+4230    /// A `NegotiatedSource` fixture. Defaults describe the common case — a
+4231    /// source the server is happy to hand over untouched — so each test varies
+4232    /// only the field it is about.
+4233    fn source_fixture() -> NegotiatedSource {
+4234        NegotiatedSource {
+4235            id: "source-1".to_string(),
+4236            supports_direct_play: true,
+4237            supports_direct_stream: true,
+4238            supports_transcoding: true,
+4239            transcoding_url: None,
+4240            bitrate: Some(6_652_961),
+4241            media_streams: Vec::new(),
+4242        }
+4243    }
+4244
+4245    /// The whole point of DR-228: a source the server will serve untouched is
+4246    /// served untouched. Before this, every video play built an HLS transcode
+4247    /// URL regardless.
+4248    ///
+4249    /// TRACES: UR-079 | DR-228 | UT-213
+4250    #[test]
+4251    fn test_a_supported_source_direct_plays() {
+4252        let source = source_fixture();
+4253        assert_eq!(
+4254            decide_playback_kind(&source, false, false),
+4255            PlaybackKind::DirectPlay
+4256        );
+4257    }
+4258
+4259    /// The server can remux without re-encoding. That is not a transcode and
+4260    /// must not be reported as one — the difference is a whole CPU core.
+4261    ///
+4262    /// TRACES: UR-079 | DR-228 | UT-213
+4263    #[test]
+4264    fn test_a_remuxable_source_direct_streams() {
+4265        let source = NegotiatedSource {
+4266            supports_direct_play: false,
+4267            supports_direct_stream: true,
+4268            ..source_fixture()
+4269        };
+4270        let kind = decide_playback_kind(&source, false, false);
+4271        assert_eq!(kind, PlaybackKind::DirectStream);
+4272        assert!(
+4273            !kind.needs_transcoding(),
+4274            "a remux costs no encoder time and must not be reported as transcoding"
+4275        );
+4276    }
+4277
+4278    /// An unsupported codec — the hevc that is ~80% of the sampled library,
+4279    /// under the Linux h264-only profile — transcodes.
+4280    ///
+4281    /// TRACES: UR-079 | DR-228 | UT-213
+4282    #[test]
+4283    fn test_an_unsupported_source_transcodes() {
+4284        let source = NegotiatedSource {
+4285            supports_direct_play: false,
+4286            supports_direct_stream: false,
+4287            ..source_fixture()
+4288        };
+4289        assert_eq!(
+4290            decide_playback_kind(&source, false, false),
+4291            PlaybackKind::Transcode
+4292        );
+4293    }
+4294
+4295    /// The override that exists because Jellyfin 10.11.5 ignores a
+4296    /// DirectPlayProfile's audio codec: it offers direct play for an E-AC-3
+4297    /// track the webview cannot decode, which renders as picture with no sound.
+4298    /// The client's verdict has to win over the server's.
+4299    ///
+4300    /// TRACES: UR-079 | DR-228, DR-148 | UT-213
+4301    #[test]
+4302    fn test_undecodable_audio_overrides_the_servers_direct_play_offer() {
+4303        let source = source_fixture();
+4304        assert!(source.supports_direct_play, "the server said yes");
+4305        assert_eq!(
+4306            decide_playback_kind(&source, true, false),
+4307            PlaybackKind::Transcode,
+4308            "silent direct play is worse than a transcode"
+4309        );
+4310    }
+4311
+4312    /// A pinned audio track cannot be served by a file whose default track is a
+4313    /// different one. Honouring the viewer's choice means asking the server to
+4314    /// build a stream around it.
+4315    ///
+4316    /// TRACES: UR-021, UR-079 | DR-228 | UT-213
+4317    #[test]
+4318    fn test_pinning_an_audio_track_forces_a_transcode() {
+4319        let source = source_fixture();
+4320        assert_eq!(
+4321            decide_playback_kind(&source, false, true),
+4322            PlaybackKind::Transcode
+4323        );
+4324    }
+4325
+4326    /// A ceiling below the source bitrate has to transcode even though the
+4327    /// codec is fine — that is the only way a cap is actually honoured. The
+4328    /// server enforces this via `MaxStaticBitrate` in the profile we send, so it
+4329    /// arrives here as `supports_direct_play: false`; this pins the mapping so a
+4330    /// future refactor cannot quietly direct-play past a cap.
+4331    ///
+4332    /// TRACES: UR-074, UR-079 | DR-226, DR-228 | UT-213
+4333    #[test]
+4334    fn test_a_ceiling_below_the_source_bitrate_transcodes() {
+4335        // 6.65 Mbps source, 2 Mbps ceiling — the server refuses direct play.
+4336        let source = NegotiatedSource {
+4337            supports_direct_play: false,
+4338            supports_direct_stream: false,
+4339            bitrate: Some(6_652_961),
+4340            ..source_fixture()
+4341        };
+4342        assert_eq!(
+4343            decide_playback_kind(&source, false, false),
+4344            PlaybackKind::Transcode
+4345        );
+4346
+4347        // And the ladder marks 2 Mbps as genuinely constraining for it.
+4348        let options =
+4349            crate::repository::stream_selection::quality_options_for_source(Some(6_652_961));
+4350        let two_mbps = options
+4351            .iter()
+4352            .find(|o| o.quality == StreamingQuality::Mbps2)
+4353            .expect("2 Mbps is on the ladder");
+4354        assert!(!two_mbps.exceeds_source);
+4355    }
+4356
+4357    /// Direct play wins over direct stream when both are on offer: copying the
+4358    /// file is strictly cheaper than repackaging it.
+4359    ///
+4360    /// TRACES: UR-079 | DR-228 | UT-213
+4361    #[test]
+4362    fn test_direct_play_is_preferred_over_direct_stream() {
+4363        let source = source_fixture();
+4364        assert!(source.supports_direct_play && source.supports_direct_stream);
+4365        assert_eq!(
+4366            decide_playback_kind(&source, false, false),
+4367            PlaybackKind::DirectPlay
+4368        );
+4369    }
+4370
+4371    // -----------------------------------------------------------------------
+4372    // Per-playback quality ceiling (DR-226)
+4373    // -----------------------------------------------------------------------
+4374
+4375    /// The defect DR-226 exists to fix: the in-player picker documented itself
+4376    /// as a "this film, this connection" control but was implemented by writing
+4377    /// the device default, so one awkward film silently capped everything played
+4378    /// afterwards. The override must not touch the default.
+4379    ///
+4380    /// TRACES: UR-074, UR-079 | DR-226 | UT-213
+4381    #[test]
+4382    fn test_a_playback_override_does_not_disturb_the_device_default() {
+4383        let _guard = QUALITY_LOCK.lock_safe();
+4384        set_streaming_quality(StreamingQuality::Mbps10);
+4385        clear_playback_quality_override();
+4386        assert_eq!(effective_streaming_quality(), StreamingQuality::Mbps10);
+4387
+4388        set_playback_quality_override(StreamingQuality::Kbps720);
+4389        assert_eq!(
+4390            effective_streaming_quality(),
+4391            StreamingQuality::Kbps720,
+4392            "the override governs the stream being opened now"
+4393        );
+4394        assert_eq!(
+4395            streaming_quality(),
+4396            StreamingQuality::Mbps10,
+4397            "but the durable default the Settings screen shows is untouched"
+4398        );
+4399
+4400        clear_playback_quality_override();
+4401        assert_eq!(
+4402            effective_streaming_quality(),
+4403            StreamingQuality::Mbps10,
+4404            "and dropping the override returns to it"
+4405        );
+4406        set_streaming_quality(StreamingQuality::Original);
+4407    }
+4408
+4409    /// A ceiling chosen for one film must not govern the next one — the
+4410    /// autoplayed next episode is the case that matters, since nobody reopens
+4411    /// the picker between episodes.
+4412    ///
+4413    /// TRACES: UR-074, UR-079 | DR-226 | UT-213
+4414    #[test]
+4415    fn test_the_override_is_droppable_so_it_cannot_outlive_its_playback() {
+4416        let _guard = QUALITY_LOCK.lock_safe();
+4417        set_streaming_quality(StreamingQuality::Original);
+4418        set_playback_quality_override(StreamingQuality::Mbps1);
+4419        assert_eq!(playback_quality_override(), Some(StreamingQuality::Mbps1));
+4420
+4421        clear_playback_quality_override();
+4422        assert_eq!(playback_quality_override(), None);
+4423        assert_eq!(effective_streaming_quality(), StreamingQuality::Original);
+4424    }
+4425}
+
\ 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/stream_selection.rs.html b/api/src/jellytau_lib/repository/stream_selection.rs.html new file mode 100644 index 00000000..a0be218b --- /dev/null +++ b/api/src/jellytau_lib/repository/stream_selection.rs.html @@ -0,0 +1,456 @@ +stream_selection.rs - sourceSkip to main content

jellytau_lib/repository/
stream_selection.rs

1//! What stream to play, decided in Rust and handed to a player whole.
+2//!
+3//! Every player backend — mpv, ExoPlayer, the webview `<video>`/hls.js path —
+4//! used to receive a bare URL and re-derive the rest: the frontend decided
+5//! "is this HLS?" by looking for `.m3u8` in the string, and nothing anywhere
+6//! carried *why* a stream was transcoded or what else the source could have
+7//! offered. This module is the replacement contract: one self-describing
+8//! [`StreamSelection`] that says what the stream is, how to fetch it, and what
+9//! the alternatives were.
+10//!
+11//! The division of labour it encodes — **Rust decides *what stream*, the player
+12//! decides *how to deliver it*** — is the point. A multi-variant playlist handed
+13//! to ExoPlayer is still ExoPlayer's to adapt over; Rust never paces bytes.
+14//!
+15//! TRACES: UR-079 | DR-225
+16
+17use serde::{Deserialize, Serialize};
+18
+19use crate::settings::StreamingQuality;
+20
+21/// How the bytes of a chosen stream are fetched.
+22///
+23/// This field exists to delete a substring search. The frontend previously
+24/// decided which loader to attach by testing `url.contains(".m3u8")`, which is a
+25/// domain fact reconstructed in the presentation layer — the same class of leak
+26/// as the item-type taxonomy that `check:boundary` guards, and one that breaks
+27/// silently the moment a server serves a playlist from a path that does not end
+28/// in `.m3u8`, or serves a progressive file from one that does.
+29///
+30/// Tagged (`{"type":"hls"}`) rather than a bare string so the frontend matches a
+31/// discriminant instead of comparing text.
+32///
+33/// TRACES: UR-079 | DR-225
+34#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+35#[serde(tag = "type", rename_all = "camelCase")]
+36pub enum Transport {
+37    /// An HLS playlist. The webview attaches hls.js (or Safari's native loader);
+38    /// ExoPlayer uses its HLS media source.
+39    Hls,
+40    /// A single progressive HTTP resource, seekable by byte range.
+41    Progressive,
+42    /// A file already on disk — a completed download, or the loopback media
+43    /// server standing in front of one.
+44    LocalFile,
+45}
+46
+47/// What the server is doing to the source to produce this stream.
+48///
+49/// Distinct from [`Transport`] because the two are genuinely independent: a
+50/// direct-streamed remux and a transcode can both arrive over HLS, and a direct
+51/// play can arrive progressively or as a local file. Keeping them apart is what
+52/// lets the UI say "this is not costing the server anything" without inferring
+53/// it from a URL shape.
+54///
+55/// TRACES: UR-079 | DR-228
+56#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+57#[serde(tag = "type", rename_all = "camelCase")]
+58pub enum PlaybackKind {
+59    /// The source file is served untouched. No server CPU, no quality loss.
+60    DirectPlay,
+61    /// The container is repackaged but the codecs are copied — cheap, and
+62    /// visually identical to the source.
+63    DirectStream,
+64    /// The server is re-encoding. The only case where a bitrate ceiling can
+65    /// actually be honoured, and the only one that costs the server real work.
+66    Transcode,
+67}
+68
+69impl PlaybackKind {
+70    /// Whether the server is spending encoder time on this stream.
+71    ///
+72    /// The queue carries a `needs_transcoding` flag that predates this enum and
+73    /// that several seek/reload paths still branch on; this keeps the two from
+74    /// drifting by making one derive from the other.
+75    ///
+76    /// TRACES: UR-079 | DR-228
+77    pub fn needs_transcoding(&self) -> bool {
+78        matches!(self, PlaybackKind::Transcode)
+79    }
+80}
+81
+82/// The rendition actually negotiated — what the viewer is receiving right now.
+83///
+84/// `None` on a [`StreamSelection`] when the source is being direct-played as-is:
+85/// there is no *chosen* rendition in that case, only the file itself, and
+86/// reporting the ceiling that happened to be set would misdescribe it.
+87///
+88/// TRACES: UR-079 | DR-225, DR-226
+89#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+90#[serde(rename_all = "camelCase")]
+91pub struct Rendition {
+92    /// The rung of the ladder this stream was built against.
+93    pub quality: StreamingQuality,
+94    /// Total bits per second the stream may use, when a ceiling applies.
+95    pub max_bitrate: Option<u64>,
+96    /// Resolution ceiling, when one applies. `None` preserves the source's.
+97    pub max_height: Option<u32>,
+98    /// Video codec the server was asked to produce.
+99    pub video_codec: Option<String>,
+100    /// Audio codec the server was asked to produce.
+101    pub audio_codec: Option<String>,
+102}
+103
+104/// One rung of the quality picker, as it applies to *this* media source.
+105///
+106/// The picker used to be filled from the fixed [`StreamingQuality::ALL`] ladder,
+107/// which meant offering "20 Mbps" for a 1.1 Mbps podcast — eight rungs, six of
+108/// them indistinguishable from Original. `exceeds_source` is what lets the
+109/// frontend render that honestly without knowing anything about bitrates.
+110///
+111/// TRACES: UR-070, UR-079 | DR-227, DR-121
+112#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+113#[serde(rename_all = "camelCase")]
+114pub struct QualityOption {
+115    pub quality: StreamingQuality,
+116    /// Human label ("8 Mbps"). Lives in Rust beside the number it describes.
+117    pub label: String,
+118    /// Secondary line ("1080p").
+119    pub detail: String,
+120    /// True when this rung's ceiling is at or above what the source itself
+121    /// carries, so selecting it yields the same stream as `Original`.
+122    ///
+123    /// The frontend renders these differently (or hides them); it does not
+124    /// decide which they are.
+125    pub exceeds_source: bool,
+126    /// The source's own bitrate, when the server reported one. Presentation
+127    /// only — the picker shows "Original (6.7 Mbps)" rather than a bare word.
+128    pub source_bitrate: Option<u64>,
+129}
+130
+131/// Everything a player backend needs to open a stream, and everything the UI
+132/// needs to describe it.
+133///
+134/// Replaces the bare `String` URL that `get_video_stream_url` used to return.
+135///
+136/// TRACES: UR-079 | DR-225, DR-227, DR-228
+137#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+138#[serde(rename_all = "camelCase")]
+139pub struct StreamSelection {
+140    /// The URL (or loopback URL) to open.
+141    pub url: String,
+142    /// How to fetch it. Replaces the `.m3u8` substring check.
+143    pub transport: Transport,
+144    /// What the server is doing to the source to produce it.
+145    pub playback_kind: PlaybackKind,
+146    /// The negotiated rendition; `None` when direct-playing the source as-is.
+147    pub rendition: Option<Rendition>,
+148    /// What this media source can offer, for the quality picker (DR-227).
+149    pub available: Vec<QualityOption>,
+150    /// The media source this selection is for, so a later re-open (quality
+151    /// change, audio-track switch, transcoded seek) targets the same one.
+152    pub media_source_id: Option<String>,
+153    /// The transcode identity the server keyed this job by, when there is one.
+154    pub play_session_id: Option<String>,
+155    /// Whether the server is spending encoder time on this stream.
+156    ///
+157    /// Derived from [`playback_kind`](Self::playback_kind) rather than left for
+158    /// the frontend to compute: "which kinds count as transcoding" is a domain
+159    /// rule, and a direct *stream* is a remux that must not be counted. The
+160    /// queue's long-standing `needs_transcoding` flag and the seek strategy both
+161    /// read this, so there is one answer rather than three.
+162    ///
+163    /// TRACES: UR-079 | DR-225, DR-228
+164    pub needs_transcoding: bool,
+165}
+166
+167impl StreamSelection {
+168    /// A selection for a file already on disk.
+169    ///
+170    /// A downloaded file is a direct play by definition — the bytes are the
+171    /// source's — and offering a quality ladder over it would be a lie, since
+172    /// nothing about a local file can be re-negotiated.
+173    ///
+174    /// TRACES: UR-071, UR-079 | DR-225
+175    pub fn local_file(url: impl Into<String>) -> Self {
+176        Self {
+177            url: url.into(),
+178            transport: Transport::LocalFile,
+179            playback_kind: PlaybackKind::DirectPlay,
+180            rendition: None,
+181            available: Vec::new(),
+182            media_source_id: None,
+183            play_session_id: None,
+184            needs_transcoding: false,
+185        }
+186    }
+187
+188    /// A selection for an item already sitting in the queue.
+189    ///
+190    /// The queue predates `StreamSelection`: its items carry a URL, an optional
+191    /// transport and the older `needs_transcoding` flag. This rebuilds a
+192    /// selection from those without re-negotiating with the server, so the
+193    /// controller can hand an engine an `OpenRequest` for an item it already
+194    /// holds.
+195    ///
+196    /// The transport falls back rather than being sniffed from the URL — the
+197    /// substring check is exactly what DR-230 removed. `needs_transcoding` is an
+198    /// exact stand-in because every transcode this app requests is HLS (DR-140).
+199    ///
+200    /// TRACES: UR-079, UR-081 | DR-225, DR-245
+201    pub fn for_queued_item(
+202        url: impl Into<String>,
+203        transport: Option<Transport>,
+204        needs_transcoding: bool,
+205    ) -> Self {
+206        let transport = transport.unwrap_or(if needs_transcoding {
+207            Transport::Hls
+208        } else {
+209            Transport::Progressive
+210        });
+211        Self {
+212            url: url.into(),
+213            transport,
+214            playback_kind: if needs_transcoding {
+215                PlaybackKind::Transcode
+216            } else {
+217                PlaybackKind::DirectPlay
+218            },
+219            rendition: None,
+220            available: Vec::new(),
+221            media_source_id: None,
+222            play_session_id: None,
+223            needs_transcoding,
+224        }
+225    }
+226}
+227
+228/// Build the quality ladder as it applies to a source of a known bitrate.
+229///
+230/// Every rung is returned — the picker stays a fixed, predictable list rather
+231/// than one that changes length per item — but each is marked with whether it
+232/// would actually constrain *this* source. A rung whose ceiling is at or above
+233/// the source bitrate produces the same bytes as `Original`, so presenting it as
+234/// a distinct choice is noise.
+235///
+236/// `source_bitrate` is `None` when the server did not report one (it is absent
+237/// for some containers — the sampled library has `avi` files with no bitrate at
+238/// all). In that case nothing can be judged redundant and every rung is offered,
+239/// which is the safe direction: the viewer keeps every choice they had before.
+240///
+241/// TRACES: UR-070, UR-079 | DR-227, DR-121 | UT-212
+242pub fn quality_options_for_source(source_bitrate: Option<u64>) -> Vec<QualityOption> {
+243    StreamingQuality::ALL
+244        .iter()
+245        .map(|quality| QualityOption {
+246            quality: *quality,
+247            label: quality.label().to_string(),
+248            detail: quality.detail().to_string(),
+249            exceeds_source: match (quality.max_bitrate(), source_bitrate) {
+250                // `Original` is the source; it never "exceeds" it.
+251                (None, _) => false,
+252                // Nothing known about the source — judge nothing redundant.
+253                (Some(_), None) => false,
+254                (Some(cap), Some(source)) => cap >= source,
+255            },
+256            source_bitrate,
+257        })
+258        .collect()
+259}
+260
+261#[cfg(test)]
+262mod tests {
+263    use super::*;
+264
+265    /// The tag the frontend matches on has to be exactly what it expects, and
+266    /// it is a *string in TypeScript* — nothing but a test keeps the two in step.
+267    ///
+268    /// TRACES: UR-079 | DR-225 | UT-212
+269    #[test]
+270    fn test_transport_serialises_with_the_tag_the_frontend_matches() {
+271        let cases = [
+272            (Transport::Hls, r#"{"type":"hls"}"#),
+273            (Transport::Progressive, r#"{"type":"progressive"}"#),
+274            (Transport::LocalFile, r#"{"type":"localFile"}"#),
+275        ];
+276        for (transport, expected) in cases {
+277            let json = serde_json::to_string(&transport).expect("serialises");
+278            assert_eq!(json, expected, "wire shape of {transport:?}");
+279            let back: Transport = serde_json::from_str(&json).expect("round-trips");
+280            assert_eq!(back, transport);
+281        }
+282    }
+283
+284    /// TRACES: UR-079 | DR-228 | UT-212
+285    #[test]
+286    fn test_playback_kind_serialises_with_the_tag_the_frontend_matches() {
+287        let cases = [
+288            (PlaybackKind::DirectPlay, r#"{"type":"directPlay"}"#),
+289            (PlaybackKind::DirectStream, r#"{"type":"directStream"}"#),
+290            (PlaybackKind::Transcode, r#"{"type":"transcode"}"#),
+291        ];
+292        for (kind, expected) in cases {
+293            let json = serde_json::to_string(&kind).expect("serialises");
+294            assert_eq!(json, expected, "wire shape of {kind:?}");
+295            let back: PlaybackKind = serde_json::from_str(&json).expect("round-trips");
+296            assert_eq!(back, kind);
+297        }
+298    }
+299
+300    /// Only a transcode costs the server encoder time. A direct *stream* is a
+301    /// remux — cheap, and not what `needs_transcoding` has ever meant.
+302    ///
+303    /// TRACES: UR-079 | DR-228 | UT-212
+304    #[test]
+305    fn test_only_transcode_counts_as_transcoding() {
+306        assert!(PlaybackKind::Transcode.needs_transcoding());
+307        assert!(!PlaybackKind::DirectStream.needs_transcoding());
+308        assert!(!PlaybackKind::DirectPlay.needs_transcoding());
+309    }
+310
+311    /// A local file is a direct play over a local transport, with no ladder:
+312    /// nothing about a file on disk can be re-negotiated.
+313    ///
+314    /// TRACES: UR-071, UR-079 | DR-225 | UT-212
+315    #[test]
+316    fn test_local_file_selection_offers_no_ladder() {
+317        let selection = StreamSelection::local_file("http://127.0.0.1:9000/media/x.mkv");
+318        assert_eq!(selection.transport, Transport::LocalFile);
+319        assert_eq!(selection.playback_kind, PlaybackKind::DirectPlay);
+320        assert!(selection.rendition.is_none());
+321        assert!(selection.available.is_empty());
+322        assert!(!selection.needs_transcoding);
+323    }
+324
+325    /// The camelCase rule applies to nested struct fields too, and
+326    /// `playbackKind` is the one the frontend branches on.
+327    ///
+328    /// TRACES: UR-079 | DR-225 | UT-212
+329    #[test]
+330    fn test_stream_selection_fields_are_camel_case_on_the_wire() {
+331        let selection = StreamSelection {
+332            url: "https://example/master.m3u8".to_string(),
+333            transport: Transport::Hls,
+334            playback_kind: PlaybackKind::Transcode,
+335            rendition: Some(Rendition {
+336                quality: StreamingQuality::Mbps8,
+337                max_bitrate: Some(8_000_000),
+338                max_height: Some(1080),
+339                video_codec: Some("h264".to_string()),
+340                audio_codec: Some("aac".to_string()),
+341            }),
+342            available: Vec::new(),
+343            media_source_id: Some("src-1".to_string()),
+344            play_session_id: Some("sess-1".to_string()),
+345            needs_transcoding: true,
+346        };
+347        let json = serde_json::to_string(&selection).expect("serialises");
+348        assert!(
+349            json.contains(r#""playbackKind":{"type":"transcode"}"#),
+350            "{json}"
+351        );
+352        assert!(json.contains(r#""transport":{"type":"hls"}"#), "{json}");
+353        assert!(json.contains(r#""mediaSourceId":"src-1""#), "{json}");
+354        assert!(json.contains(r#""playSessionId":"sess-1""#), "{json}");
+355        assert!(json.contains(r#""maxBitrate":8000000"#), "{json}");
+356        assert!(json.contains(r#""maxHeight":1080"#), "{json}");
+357        assert!(json.contains(r#""needsTranscoding":true"#), "{json}");
+358    }
+359
+360    /// The measured library has 1.1 Mbps sources in it. Offering those a choice
+361    /// of 20, 10, 8, 4 and 2 Mbps is offering five ways to spell "Original".
+362    ///
+363    /// TRACES: UR-070, UR-079 | DR-227, DR-121 | UT-212
+364    #[test]
+365    fn test_rungs_above_the_source_bitrate_are_marked_redundant() {
+366        let options = quality_options_for_source(Some(1_122_137));
+367        let redundant: Vec<_> = options
+368            .iter()
+369            .filter(|o| o.exceeds_source)
+370            .map(|o| o.quality)
+371            .collect();
+372        assert_eq!(
+373            redundant,
+374            vec![
+375                StreamingQuality::Mbps20,
+376                StreamingQuality::Mbps10,
+377                StreamingQuality::Mbps8,
+378                StreamingQuality::Mbps4,
+379                StreamingQuality::Mbps2,
+380            ],
+381            "every rung at or above a 1.12 Mbps source is the source"
+382        );
+383
+384        // The rungs that genuinely constrain it are not marked.
+385        let constraining: Vec<_> = options
+386            .iter()
+387            .filter(|o| !o.exceeds_source)
+388            .map(|o| o.quality)
+389            .collect();
+390        assert_eq!(
+391            constraining,
+392            vec![
+393                StreamingQuality::Original,
+394                StreamingQuality::Mbps1,
+395                StreamingQuality::Kbps720,
+396            ]
+397        );
+398    }
+399
+400    /// `Original` is the source, so it is never "above" it — not even for a
+401    /// source whose bitrate is unknown or zero.
+402    ///
+403    /// TRACES: UR-070, UR-079 | DR-227 | UT-212
+404    #[test]
+405    fn test_original_is_never_marked_as_exceeding_the_source() {
+406        for bitrate in [None, Some(0), Some(1), Some(50_000_000)] {
+407            let options = quality_options_for_source(bitrate);
+408            let original = options
+409                .iter()
+410                .find(|o| o.quality == StreamingQuality::Original)
+411                .expect("Original is always offered");
+412            assert!(!original.exceeds_source, "bitrate {bitrate:?}");
+413        }
+414    }
+415
+416    /// An `avi` with no reported bitrate must not lose the picker. Judging
+417    /// nothing redundant is the safe direction — the viewer keeps every choice.
+418    ///
+419    /// TRACES: UR-070, UR-079 | DR-227 | UT-212
+420    #[test]
+421    fn test_an_unknown_source_bitrate_keeps_every_rung_offered() {
+422        let options = quality_options_for_source(None);
+423        assert_eq!(options.len(), StreamingQuality::ALL.len());
+424        assert!(
+425            options.iter().all(|o| !o.exceeds_source),
+426            "nothing can be judged redundant without a source bitrate"
+427        );
+428        assert!(options.iter().all(|o| o.source_bitrate.is_none()));
+429    }
+430
+431    /// A 4K remux constrains at every rung — the ladder is fully meaningful.
+432    ///
+433    /// TRACES: UR-070, UR-079 | DR-227 | UT-212
+434    #[test]
+435    fn test_a_source_above_the_ladder_marks_nothing_redundant() {
+436        let options = quality_options_for_source(Some(40_000_000));
+437        assert!(options.iter().all(|o| !o.exceeds_source));
+438    }
+439
+440    /// The picker's text comes from Rust, beside the numbers it describes, so a
+441    /// relabelled rung cannot drift out of step with what it does.
+442    ///
+443    /// TRACES: UR-070, UR-079 | DR-227 | UT-212
+444    #[test]
+445    fn test_options_carry_the_ladder_labels() {
+446        let options = quality_options_for_source(Some(6_652_961));
+447        assert_eq!(options.len(), StreamingQuality::ALL.len());
+448        for (option, quality) in options.iter().zip(StreamingQuality::ALL) {
+449            assert_eq!(option.quality, quality);
+450            assert_eq!(option.label, quality.label());
+451            assert_eq!(option.detail, quality.detail());
+452            assert_eq!(option.source_bitrate, Some(6_652_961));
+453        }
+454    }
+455}
+
\ 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..f889cc6a --- /dev/null +++ b/api/src/jellytau_lib/repository/types.rs.html @@ -0,0 +1,1142 @@ +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    /// How to open `stream_url`.
+473    ///
+474    /// A live channel is always an HLS transcode — the server has to repackage a
+475    /// broadcast mux into something a browser can play, and there is no static
+476    /// file to direct-play. Saying so here means the player page never has to
+477    /// work it out from the URL, which is the whole of DR-225.
+478    ///
+479    /// TRACES: UR-079 | DR-225
+480    pub transport: super::stream_selection::Transport,
+481}
+482
+483/// Genre
+484#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+485#[serde(rename_all = "camelCase")]
+486pub struct Genre {
+487    pub id: String,
+488    pub name: String,
+489    /// Number of albums tagged with this genre, when the backend can supply it
+490    /// (online only). Lets the frontend rank/pick genres without probing each
+491    /// one. `None` when unknown (e.g. offline).
+492    pub album_count: Option<u32>,
+493}
+494
+495/// Image type
+496#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+497pub enum ImageType {
+498    Primary,
+499    Backdrop,
+500    Banner,
+501    Thumb,
+502    Logo,
+503}
+504
+505impl ImageType {
+506    pub fn as_str(&self) -> &str {
+507        match self {
+508            ImageType::Primary => "Primary",
+509            ImageType::Backdrop => "Backdrop",
+510            ImageType::Banner => "Banner",
+511            ImageType::Thumb => "Thumb",
+512            ImageType::Logo => "Logo",
+513        }
+514    }
+515}
+516
+517/// Image options
+518#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
+519#[serde(rename_all = "camelCase")]
+520pub struct ImageOptions {
+521    #[serde(skip_serializing_if = "Option::is_none")]
+522    pub max_width: Option<u32>,
+523    #[serde(skip_serializing_if = "Option::is_none")]
+524    pub max_height: Option<u32>,
+525    #[serde(skip_serializing_if = "Option::is_none")]
+526    pub quality: Option<u32>,
+527    #[serde(skip_serializing_if = "Option::is_none")]
+528    pub tag: Option<String>,
+529}
+530
+531/// Trait for checking if data has meaningful content
+532pub trait MeaningfulContent {
+533    fn has_content(&self) -> bool;
+534}
+535
+536impl MeaningfulContent for Vec<Library> {
+537    fn has_content(&self) -> bool {
+538        !self.is_empty()
+539    }
+540}
+541
+542impl MeaningfulContent for Vec<MediaItem> {
+543    fn has_content(&self) -> bool {
+544        !self.is_empty()
+545    }
+546}
+547
+548impl MeaningfulContent for SearchResult {
+549    fn has_content(&self) -> bool {
+550        !self.items.is_empty()
+551    }
+552}
+553
+554impl MeaningfulContent for MediaItem {
+555    fn has_content(&self) -> bool {
+556        true // A single item always has content if it exists
+557    }
+558}
+559
+560impl MeaningfulContent for Vec<Genre> {
+561    fn has_content(&self) -> bool {
+562        !self.is_empty()
+563    }
+564}
+565
+566impl MeaningfulContent for PlaybackInfo {
+567    fn has_content(&self) -> bool {
+568        !self.stream_url.is_empty()
+569    }
+570}
+571
+572/// Playlist entry — wraps a MediaItem with the Jellyfin PlaylistItemId
+573/// needed for remove/reorder operations (distinct from the media item's ID)
+574///
+575/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+576#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+577#[serde(rename_all = "camelCase")]
+578pub struct PlaylistEntry {
+579    /// The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
+580    pub playlist_item_id: String,
+581    /// The underlying media item
+582    #[serde(flatten)]
+583    pub item: MediaItem,
+584}
+585
+586/// Result of creating a playlist
+587///
+588/// @req: JA-019 - Get/create/update playlists
+589#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+590#[serde(rename_all = "camelCase")]
+591pub struct PlaylistCreatedResult {
+592    pub id: String,
+593}
+594
+595impl MeaningfulContent for Vec<PlaylistEntry> {
+596    fn has_content(&self) -> bool {
+597        !self.is_empty()
+598    }
+599}
+600
+601impl MeaningfulContent for PlaylistCreatedResult {
+602    fn has_content(&self) -> bool {
+603        !self.id.is_empty()
+604    }
+605}
+606
+607#[cfg(test)]
+608mod search_scope_tests {
+609    use super::*;
+610
+611    /// Music expands to the four Jellyfin types that make up the category.
+612    ///
+613    /// This table is the domain vocabulary that used to live in the frontend
+614    /// (`searchScope.ts`'s `SCOPE_ITEM_TYPES`) — the boundary leak that
+615    /// docs/specs/scoped-search-boundary.md was written about.
+616    ///
+617    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
+618    #[test]
+619    fn music_scope_expands_to_music_item_types() {
+620        assert_eq!(
+621            SearchScope::Music.item_types(),
+622            Some(vec![
+623                "MusicAlbum".to_string(),
+624                "MusicArtist".to_string(),
+625                "Audio".to_string(),
+626                "Playlist".to_string(),
+627            ])
+628        );
+629    }
+630
+631    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
+632    #[test]
+633    fn movies_scope_expands_to_movie_only() {
+634        assert_eq!(
+635            SearchScope::Movies.item_types(),
+636            Some(vec!["Movie".to_string()])
+637        );
+638    }
+639
+640    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
+641    #[test]
+642    fn tv_scope_expands_to_series_and_episode() {
+643        assert_eq!(
+644            SearchScope::Tv.item_types(),
+645            Some(vec!["Series".to_string(), "Episode".to_string()])
+646        );
+647    }
+648
+649    /// `All` must send NO filter — not the union of the other scopes.
+650    ///
+651    /// Sending a union would silently drop every type nobody enumerated
+652    /// (Person, folders, …), which an explicit `includeItemTypes` list filters
+653    /// out. This is why `item_types()` returns Option rather than Vec.
+654    ///
+655    /// @req-test: UT-090 - All scope sends no item-type filter
+656    #[test]
+657    fn all_scope_sends_no_filter() {
+658        assert_eq!(SearchScope::All.item_types(), None);
+659    }
+660
+661    /// Scope wins over an explicitly supplied include_item_types.
+662    ///
+663    /// @req-test: UT-091 - Scope takes precedence over include_item_types
+664    #[test]
+665    fn resolve_scope_overrides_include_item_types() {
+666        let mut options = SearchOptions {
+667            include_item_types: Some(vec!["Movie".to_string()]),
+668            scope: Some(SearchScope::Music),
+669            ..Default::default()
+670        };
+671        options.resolve_scope();
+672
+673        assert_eq!(
+674            options.include_item_types,
+675            Some(vec![
+676                "MusicAlbum".to_string(),
+677                "MusicArtist".to_string(),
+678                "Audio".to_string(),
+679                "Playlist".to_string(),
+680            ])
+681        );
+682    }
+683
+684    /// `All` clears any include_item_types so no filter reaches the query.
+685    ///
+686    /// @req-test: UT-090 - All scope sends no item-type filter
+687    #[test]
+688    fn resolve_all_scope_clears_include_item_types() {
+689        let mut options = SearchOptions {
+690            include_item_types: Some(vec!["Movie".to_string()]),
+691            scope: Some(SearchScope::All),
+692            ..Default::default()
+693        };
+694        options.resolve_scope();
+695
+696        assert_eq!(options.include_item_types, None);
+697    }
+698
+699    /// With no scope set, include_item_types passes through untouched — the
+700    /// non-search `getItems` callers rely on this.
+701    ///
+702    /// @req-test: UT-091 - Scope takes precedence over include_item_types
+703    #[test]
+704    fn resolve_without_scope_preserves_include_item_types() {
+705        let mut options = SearchOptions {
+706            include_item_types: Some(vec!["MusicAlbum".to_string()]),
+707            scope: None,
+708            ..Default::default()
+709        };
+710        options.resolve_scope();
+711
+712        assert_eq!(
+713            options.include_item_types,
+714            Some(vec!["MusicAlbum".to_string()])
+715        );
+716    }
+717
+718    /// The frontend sends the enum as camelCase over IPC.
+719    ///
+720    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
+721    #[test]
+722    fn scope_deserializes_from_camel_case() {
+723        let options: SearchOptions =
+724            serde_json::from_str(r#"{"scope": "music", "limit": 10}"#).unwrap();
+725        assert!(matches!(options.scope, Some(SearchScope::Music)));
+726
+727        let all: SearchOptions = serde_json::from_str(r#"{"scope": "all"}"#).unwrap();
+728        assert!(matches!(all.scope, Some(SearchScope::All)));
+729    }
+730
+731    /// TRACES: DR-175 | UT-161
+732    #[test]
+733    fn test_collection_type_maps_to_its_favorites_scope() {
+734        assert_eq!(
+735            SearchScope::for_collection_type("movies"),
+736            Some(SearchScope::Movies)
+737        );
+738        assert_eq!(
+739            SearchScope::for_collection_type("tvshows"),
+740            Some(SearchScope::Tv)
+741        );
+742        assert_eq!(
+743            SearchScope::for_collection_type("music"),
+744            Some(SearchScope::Music)
+745        );
+746    }
+747
+748    /// A library kind favourites are not browsed by gets no tile at all, rather
+749    /// than one that opens an unfiltered list. `All` is never derived from a
+750    /// library — it is the cross-library entry offered beside them.
+751    ///
+752    /// TRACES: DR-175 | UT-161
+753    #[test]
+754    fn test_uncategorised_collection_types_have_no_favorites_scope() {
+755        for collection_type in ["livetv", "channels", "boxsets", "books", "unknown", ""] {
+756            assert_eq!(
+757                SearchScope::for_collection_type(collection_type),
+758                None,
+759                "{collection_type} should not carry a favourites scope"
+760            );
+761        }
+762    }
+763
+764    /// TRACES: DR-175 | UT-161
+765    #[test]
+766    fn test_library_carries_its_favorites_scope_to_the_frontend() {
+767        let music = Library::new("1".into(), "Music".into(), "music".into(), None);
+768        assert_eq!(music.favorites_scope, Some(SearchScope::Music));
+769
+770        let json = serde_json::to_value(&music).unwrap();
+771        assert_eq!(json["favoritesScope"], "music");
+772
+773        // A library with no scope omits the field rather than sending null.
+774        let livetv = Library::new("2".into(), "Live TV".into(), "livetv".into(), None);
+775        let json = serde_json::to_value(&livetv).unwrap();
+776        assert!(json.get("favoritesScope").is_none());
+777    }
+778}
+779
+780#[cfg(test)]
+781mod tests {
+782    use super::*;
+783
+784    #[test]
+785    fn test_artist_item_deserialize_pascal_case() {
+786        // Test that ArtistItem correctly deserializes PascalCase JSON from Jellyfin API
+787        let json = r#"{"Id": "artist123", "Name": "Bob Dylan"}"#;
+788        let result: Result<ArtistItem, _> = serde_json::from_str(json);
+789
+790        assert!(result.is_ok());
+791        let artist = result.unwrap();
+792        assert_eq!(artist.id, "artist123");
+793        assert_eq!(artist.name, "Bob Dylan");
+794    }
+795
+796    #[test]
+797    fn test_artist_item_deserialize_array() {
+798        // Test deserializing array of ArtistItems (common in API responses)
+799        let json = r#"[
+800            {"Id": "artist1", "Name": "Bob Dylan"},
+801            {"Id": "artist2", "Name": "Johnny Cash"}
+802        ]"#;
+803        let result: Result<Vec<ArtistItem>, _> = serde_json::from_str(json);
+804
+805        assert!(result.is_ok());
+806        let artists = result.unwrap();
+807        assert_eq!(artists.len(), 2);
+808        assert_eq!(artists[0].id, "artist1");
+809        assert_eq!(artists[0].name, "Bob Dylan");
+810        assert_eq!(artists[1].id, "artist2");
+811        assert_eq!(artists[1].name, "Johnny Cash");
+812    }
+813
+814    #[test]
+815    fn test_artist_item_serialize() {
+816        // ArtistItem serializes to camelCase for the frontend; it still accepts
+817        // Jellyfin's PascalCase on deserialize via #[serde(alias = ...)].
+818        let artist = ArtistItem {
+819            id: "test-id".to_string(),
+820            name: "Test Artist".to_string(),
+821        };
+822
+823        let json = serde_json::to_string(&artist).expect("Failed to serialize");
+824        assert!(json.contains(r#""id":"test-id""#));
+825        assert!(json.contains(r#""name":"Test Artist""#));
+826
+827        let from_pascal: ArtistItem =
+828            serde_json::from_str(r#"{"Id":"x","Name":"Y"}"#).expect("Failed to deserialize");
+829        assert_eq!(from_pascal.id, "x");
+830        assert_eq!(from_pascal.name, "Y");
+831    }
+832
+833    #[test]
+834    fn test_media_item_with_primary_image_tag() {
+835        // Test that MediaItem correctly handles primary_image_tag
+836        let json = r#"{
+837            "id": "item123",
+838            "name": "Test Item",
+839            "type": "MusicAlbum",
+840            "serverId": "server1",
+841            "primaryImageTag": "tag123"
+842        }"#;
+843
+844        let result: Result<MediaItem, _> = serde_json::from_str(json);
+845        assert!(result.is_ok());
+846
+847        let item = result.unwrap();
+848        assert_eq!(item.id, "item123");
+849        assert_eq!(item.name, "Test Item");
+850        assert_eq!(item.primary_image_tag, Some("tag123".to_string()));
+851    }
+852
+853    #[test]
+854    fn test_media_item_with_artists() {
+855        // Test MediaItem with artists array
+856        let json = r#"{
+857            "id": "track1",
+858            "name": "Test Track",
+859            "type": "Audio",
+860            "serverId": "server1",
+861            "artists": ["Artist 1", "Artist 2"]
+862        }"#;
+863
+864        let result: Result<MediaItem, _> = serde_json::from_str(json);
+865        assert!(result.is_ok());
+866
+867        let item = result.unwrap();
+868        let artists = item.artists.expect("Expected artists");
+869        assert_eq!(artists.len(), 2);
+870        assert_eq!(artists[0], "Artist 1");
+871        assert_eq!(artists[1], "Artist 2");
+872    }
+873
+874    #[test]
+875    fn test_search_result_meaningful_content() {
+876        // Test MeaningfulContent trait for SearchResult
+877        let empty_result = SearchResult {
+878            items: vec![],
+879            total_record_count: 0,
+880        };
+881        assert!(!empty_result.has_content());
+882
+883        let non_empty_result = SearchResult {
+884            items: vec![MediaItem {
+885                id: "1".to_string(),
+886                name: "Test".to_string(),
+887                item_type: "Audio".to_string(),
+888                kind: crate::domain::MediaKind::Track,
+889                is_folder: false,
+890                server_id: "server1".to_string(),
+891                parent_id: None,
+892                library_id: None,
+893                overview: None,
+894                genres: None,
+895                production_year: None,
+896                premiere_date: None,
+897                community_rating: None,
+898                official_rating: None,
+899                runtime_ticks: None,
+900                duration_ms: None,
+901                primary_image_tag: None,
+902                image_id: None,
+903                backdrop_image_tags: None,
+904                parent_backdrop_image_tags: None,
+905                album_id: None,
+906                album_name: None,
+907                album_artist: None,
+908                artists: None,
+909                artist_items: None,
+910                index_number: None,
+911                parent_index_number: None,
+912                series_id: None,
+913                series_name: None,
+914                season_id: None,
+915                season_name: None,
+916                user_data: None,
+917                media_streams: None,
+918                media_sources: None,
+919                people: None,
+920            }],
+921            total_record_count: 1,
+922        };
+923        assert!(non_empty_result.has_content());
+924    }
+925
+926    #[test]
+927    fn test_person_deserialize_complete() {
+928        // Test that Person deserializes correctly with all fields (PascalCase from Jellyfin API)
+929        let json = r#"{
+930            "Id": "person123",
+931            "Name": "Tom Hanks",
+932            "Type": "Actor",
+933            "Role": "Lead Actor",
+934            "PrimaryImageTag": "tag456"
+935        }"#;
+936
+937        let result: Result<Person, _> = serde_json::from_str(json);
+938        assert!(result.is_ok(), "Failed to deserialize: {:?}", result.err());
+939
+940        let person = result.unwrap();
+941        assert_eq!(person.id, "person123");
+942        assert_eq!(person.name, "Tom Hanks");
+943        assert_eq!(person.person_type, "Actor");
+944        assert_eq!(person.role, Some("Lead Actor".to_string()));
+945        assert_eq!(person.primary_image_tag, Some("tag456".to_string()));
+946
+947        // Verify serialization uses camelCase for frontend
+948        let serialized = serde_json::to_string(&person).expect("Failed to serialize");
+949        assert!(
+950            serialized.contains(r#""type":"Actor""#),
+951            "Serialized form should use 'type' not 'Type'"
+952        );
+953        assert!(serialized.contains(r#""id":"person123""#));
+954        assert!(serialized.contains(r#""primaryImageTag":"tag456""#));
+955    }
+956
+957    #[test]
+958    fn test_person_deserialize_minimal() {
+959        // Test that Person deserializes with missing optional fields (uses defaults)
+960        let json = r#"{
+961            "Id": "person456",
+962            "Name": "Meryl Streep",
+963            "Type": "Actress"
+964        }"#;
+965
+966        let result: Result<Person, _> = serde_json::from_str(json);
+967        assert!(result.is_ok());
+968
+969        let person = result.unwrap();
+970        assert_eq!(person.id, "person456");
+971        assert_eq!(person.name, "Meryl Streep");
+972        assert_eq!(person.person_type, "Actress");
+973        assert_eq!(person.role, None);
+974        assert_eq!(person.primary_image_tag, None);
+975    }
+976
+977    #[test]
+978    fn test_person_array_deserialize() {
+979        // Test deserializing array of Person objects (common in Jellyfin API)
+980        let json = r#"[
+981            {"Id": "actor1", "Name": "Actor One", "Type": "Actor"},
+982            {"Id": "director1", "Name": "Director One", "Type": "Director", "Role": "Director"}
+983        ]"#;
+984
+985        let result: Result<Vec<Person>, _> = serde_json::from_str(json);
+986        assert!(result.is_ok());
+987
+988        let people = result.unwrap();
+989        assert_eq!(people.len(), 2);
+990        assert_eq!(people[0].name, "Actor One");
+991        assert_eq!(people[1].person_type, "Director");
+992        assert_eq!(people[1].role, Some("Director".to_string()));
+993    }
+994
+995    #[test]
+996    fn test_media_item_with_people() {
+997        // Test that MediaItem correctly deserializes with people array (from API in PascalCase)
+998        let json = r#"{
+999            "id": "movie1",
+1000            "name": "Test Movie",
+1001            "type": "Movie",
+1002            "serverId": "server1",
+1003            "people": [
+1004                {"Id": "actor1", "Name": "John Doe", "Type": "Actor"},
+1005                {"Id": "director1", "Name": "Jane Smith", "Type": "Director"}
+1006            ]
+1007        }"#;
+1008
+1009        let result: Result<MediaItem, _> = serde_json::from_str(json);
+1010        assert!(result.is_ok());
+1011
+1012        let item = result.unwrap();
+1013        let people = item.people.as_ref().expect("Expected people array");
+1014        assert_eq!(people.len(), 2);
+1015        assert_eq!(people[0].name, "John Doe");
+1016        assert_eq!(people[0].person_type, "Actor");
+1017        assert_eq!(people[1].person_type, "Director");
+1018
+1019        // Verify that when serialized to frontend, it uses camelCase
+1020        let serialized = serde_json::to_string(&item).expect("Failed to serialize");
+1021        let re_parsed: serde_json::Value =
+1022            serde_json::from_str(&serialized).expect("Failed to parse serialized");
+1023        let people_array = re_parsed["people"]
+1024            .as_array()
+1025            .expect("people should be array");
+1026        assert!(
+1027            people_array[0].get("type").is_some(),
+1028            "Serialized person should have 'type' field"
+1029        );
+1030        assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor");
+1031    }
+1032
+1033    #[test]
+1034    fn test_playlist_entry_serialization() {
+1035        let entry = PlaylistEntry {
+1036            playlist_item_id: "entry-abc-123".to_string(),
+1037            item: MediaItem {
+1038                id: "track1".to_string(),
+1039                name: "Test Track".to_string(),
+1040                item_type: "Audio".to_string(),
+1041                kind: crate::domain::MediaKind::Track,
+1042                is_folder: false,
+1043                server_id: "server1".to_string(),
+1044                parent_id: None,
+1045                library_id: None,
+1046                overview: None,
+1047                genres: None,
+1048                production_year: None,
+1049                premiere_date: None,
+1050                community_rating: None,
+1051                official_rating: None,
+1052                runtime_ticks: None,
+1053                duration_ms: None,
+1054                primary_image_tag: None,
+1055                image_id: None,
+1056                backdrop_image_tags: None,
+1057                parent_backdrop_image_tags: None,
+1058                album_id: None,
+1059                album_name: None,
+1060                album_artist: None,
+1061                artists: Some(vec!["Artist One".to_string()]),
+1062                artist_items: None,
+1063                index_number: None,
+1064                parent_index_number: None,
+1065                series_id: None,
+1066                series_name: None,
+1067                season_id: None,
+1068                season_name: None,
+1069                user_data: None,
+1070                media_streams: None,
+1071                media_sources: None,
+1072                people: None,
+1073            },
+1074        };
+1075
+1076        let json = serde_json::to_string(&entry).expect("Failed to serialize");
+1077        // playlistItemId is camelCase
+1078        assert!(json.contains(r#""playlistItemId":"entry-abc-123""#));
+1079        // Flattened MediaItem fields appear at top level
+1080        assert!(json.contains(r#""id":"track1""#));
+1081        assert!(json.contains(r#""name":"Test Track""#));
+1082        assert!(json.contains(r#""type":"Audio""#));
+1083    }
+1084
+1085    #[test]
+1086    fn test_playlist_created_result_serialization() {
+1087        let result = PlaylistCreatedResult {
+1088            id: "playlist-new-123".to_string(),
+1089        };
+1090        let json = serde_json::to_string(&result).expect("Failed to serialize");
+1091        assert!(json.contains(r#""id":"playlist-new-123""#));
+1092    }
+1093
+1094    #[test]
+1095    fn test_playlist_entry_meaningful_content() {
+1096        let empty: Vec<PlaylistEntry> = vec![];
+1097        assert!(!empty.has_content());
+1098
+1099        let non_empty = vec![PlaylistEntry {
+1100            playlist_item_id: "e1".to_string(),
+1101            item: MediaItem {
+1102                id: "1".to_string(),
+1103                name: "Track".to_string(),
+1104                item_type: "Audio".to_string(),
+1105                kind: crate::domain::MediaKind::Track,
+1106                is_folder: false,
+1107                server_id: "s1".to_string(),
+1108                parent_id: None,
+1109                library_id: None,
+1110                overview: None,
+1111                genres: None,
+1112                production_year: None,
+1113                premiere_date: None,
+1114                community_rating: None,
+1115                official_rating: None,
+1116                runtime_ticks: None,
+1117                duration_ms: None,
+1118                primary_image_tag: None,
+1119                image_id: None,
+1120                backdrop_image_tags: None,
+1121                parent_backdrop_image_tags: None,
+1122                album_id: None,
+1123                album_name: None,
+1124                album_artist: None,
+1125                artists: None,
+1126                artist_items: None,
+1127                index_number: None,
+1128                parent_index_number: None,
+1129                series_id: None,
+1130                series_name: None,
+1131                season_id: None,
+1132                season_name: None,
+1133                user_data: None,
+1134                media_streams: None,
+1135                media_sources: None,
+1136                people: None,
+1137            },
+1138        }];
+1139        assert!(non_empty.has_content());
+1140    }
+1141}
+
\ 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 Phase",0],["impl Clone for Output",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 PlaybackKind",0],["impl Clone for Transport",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 Capabilities",0],["impl Clone for OpenRequest",0],["impl Clone for PlaybackSnapshot",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 QualityOption",0],["impl Clone for Rendition",0],["impl Clone for StreamSelection",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":[43414]} \ 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..32e7cc6e --- /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 Phase",0],["impl Eq for Output",0],["impl Eq for AddPosition",0],["impl Eq for RepeatMode",0],["impl Eq for VideoSeekStrategy",0],["impl Eq for EndReason",0],["impl Eq for PlaybackKind",0],["impl Eq for Transport",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 Capabilities",0],["impl Eq for QualityOption",0],["impl Eq for Rendition",0],["impl Eq for StreamSelection",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":[9122]} \ 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..20b3d454 --- /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 Phase",0],["impl PartialEq for Output",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 PlaybackKind",0],["impl PartialEq for Transport",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 Capabilities",0],["impl PartialEq for QualityOption",0],["impl PartialEq for Rendition",0],["impl PartialEq for StreamSelection",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":[13163]} \ 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..0e554b15 --- /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 PlaybackSnapshot",0],["impl Default for Shared",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":[11578]} \ 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..89c34982 --- /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 Phase",0],["impl Debug for Output",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 PlaybackKind",0],["impl Debug for Transport",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 Capabilities",0],["impl Debug for OpenRequest",0],["impl Debug for PlaybackSnapshot",0],["impl Debug for Shared",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 DeviceProfile",0],["impl Debug for DirectPlayProfile",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 NegotiatedSource",0],["impl Debug for NegotiatedStream",0],["impl Debug for PlaybackInfoRequest",0],["impl Debug for PlaybackInfoResponse",0],["impl Debug for PlaylistItemsResponse",0],["impl Debug for SubtitleProfile",0],["impl Debug for TranscodingProfile",0],["impl Debug for QualityOption",0],["impl Debug for Rendition",0],["impl Debug for StreamSelection",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":[56650]} \ 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..10edb997 --- /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 Output",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 PlaybackKind",0],["impl Copy for Transport",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 Capabilities",0],["impl Copy for ObservedTime",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[6658]} \ 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..05d0f37f --- /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 Phase",0,1,["jellytau_lib::player::media_player::Phase"]],["impl Freeze for Output",0,1,["jellytau_lib::player::mpv_player::Output"]],["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 PlaybackKind",0,1,["jellytau_lib::repository::stream_selection::PlaybackKind"]],["impl Freeze for Transport",0,1,["jellytau_lib::repository::stream_selection::Transport"]],["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 Capabilities",0,1,["jellytau_lib::player::media_player::Capabilities"]],["impl Freeze for OpenRequest",0,1,["jellytau_lib::player::media_player::OpenRequest"]],["impl Freeze for PlaybackSnapshot",0,1,["jellytau_lib::player::media_player::PlaybackSnapshot"]],["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 MpvPlayer",0,1,["jellytau_lib::player::mpv_player::MpvPlayer"]],["impl Freeze for Shared",0,1,["jellytau_lib::player::mpv_player::Shared"]],["impl Freeze for Gl",0,1,["jellytau_lib::player::mpv_render::Gl"]],["impl Freeze for MpvRenderContext",0,1,["jellytau_lib::player::mpv_render::MpvRenderContext"]],["impl Freeze for Target",0,1,["jellytau_lib::player::mpv_render::Target"]],["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 SurfaceState",0,1,["jellytau_lib::player::video_surface::SurfaceState"]],["impl Freeze for VideoSurface",0,1,["jellytau_lib::player::video_surface::VideoSurface"]],["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 DeviceProfile",0,1,["jellytau_lib::repository::online::DeviceProfile"]],["impl Freeze for DirectPlayProfile",0,1,["jellytau_lib::repository::online::DirectPlayProfile"]],["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 NegotiatedSource",0,1,["jellytau_lib::repository::online::NegotiatedSource"]],["impl Freeze for NegotiatedStream",0,1,["jellytau_lib::repository::online::NegotiatedStream"]],["impl Freeze for OnlineRepository",0,1,["jellytau_lib::repository::online::OnlineRepository"]],["impl Freeze for PlaybackInfoRequest",0,1,["jellytau_lib::repository::online::PlaybackInfoRequest"]],["impl Freeze for PlaybackInfoResponse",0,1,["jellytau_lib::repository::online::PlaybackInfoResponse"]],["impl Freeze for PlaylistItemsResponse",0,1,["jellytau_lib::repository::online::PlaylistItemsResponse"]],["impl Freeze for SubtitleProfile",0,1,["jellytau_lib::repository::online::SubtitleProfile"]],["impl Freeze for TranscodingProfile",0,1,["jellytau_lib::repository::online::TranscodingProfile"]],["impl Freeze for QualityOption",0,1,["jellytau_lib::repository::stream_selection::QualityOption"]],["impl Freeze for Rendition",0,1,["jellytau_lib::repository::stream_selection::Rendition"]],["impl Freeze for StreamSelection",0,1,["jellytau_lib::repository::stream_selection::StreamSelection"]],["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"]],["impl<B> Freeze for LegacyPlayer<B>
where\n B: Freeze,
",0,1,["jellytau_lib::player::legacy_player::LegacyPlayer"]]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[93288]} \ 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..e859b07c --- /dev/null +++ b/api/trait.impl/core/marker/trait.Send.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl !Send for SurfaceState",1,1,["jellytau_lib::player::video_surface::SurfaceState"]],["impl !Send for VideoSurface",1,1,["jellytau_lib::player::video_surface::VideoSurface"]],["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 Phase",0,1,["jellytau_lib::player::media_player::Phase"]],["impl Send for Output",0,1,["jellytau_lib::player::mpv_player::Output"]],["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 PlaybackKind",0,1,["jellytau_lib::repository::stream_selection::PlaybackKind"]],["impl Send for Transport",0,1,["jellytau_lib::repository::stream_selection::Transport"]],["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 Capabilities",0,1,["jellytau_lib::player::media_player::Capabilities"]],["impl Send for OpenRequest",0,1,["jellytau_lib::player::media_player::OpenRequest"]],["impl Send for PlaybackSnapshot",0,1,["jellytau_lib::player::media_player::PlaybackSnapshot"]],["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 MpvPlayer",0,1,["jellytau_lib::player::mpv_player::MpvPlayer"]],["impl Send for Shared",0,1,["jellytau_lib::player::mpv_player::Shared"]],["impl Send for Gl",0,1,["jellytau_lib::player::mpv_render::Gl"]],["impl Send for MpvRenderContext",0],["impl Send for Target",0,1,["jellytau_lib::player::mpv_render::Target"]],["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 DeviceProfile",0,1,["jellytau_lib::repository::online::DeviceProfile"]],["impl Send for DirectPlayProfile",0,1,["jellytau_lib::repository::online::DirectPlayProfile"]],["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 NegotiatedSource",0,1,["jellytau_lib::repository::online::NegotiatedSource"]],["impl Send for NegotiatedStream",0,1,["jellytau_lib::repository::online::NegotiatedStream"]],["impl Send for OnlineRepository",0,1,["jellytau_lib::repository::online::OnlineRepository"]],["impl Send for PlaybackInfoRequest",0,1,["jellytau_lib::repository::online::PlaybackInfoRequest"]],["impl Send for PlaybackInfoResponse",0,1,["jellytau_lib::repository::online::PlaybackInfoResponse"]],["impl Send for PlaylistItemsResponse",0,1,["jellytau_lib::repository::online::PlaylistItemsResponse"]],["impl Send for SubtitleProfile",0,1,["jellytau_lib::repository::online::SubtitleProfile"]],["impl Send for TranscodingProfile",0,1,["jellytau_lib::repository::online::TranscodingProfile"]],["impl Send for QualityOption",0,1,["jellytau_lib::repository::stream_selection::QualityOption"]],["impl Send for Rendition",0,1,["jellytau_lib::repository::stream_selection::Rendition"]],["impl Send for StreamSelection",0,1,["jellytau_lib::repository::stream_selection::StreamSelection"]],["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"]],["impl<B> Send for LegacyPlayer<B>",0,1,["jellytau_lib::player::legacy_player::LegacyPlayer"]]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[91572]} \ 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..ef1ae7b1 --- /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 Phase",0],["impl StructuralPartialEq for Output",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 PlaybackKind",0],["impl StructuralPartialEq for Transport",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 Capabilities",0],["impl StructuralPartialEq for QualityOption",0],["impl StructuralPartialEq for Rendition",0],["impl StructuralPartialEq for StreamSelection",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":[14675]} \ 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..7e25851e --- /dev/null +++ b/api/trait.impl/core/marker/trait.Sync.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl !Sync for MpvRenderContext",1,1,["jellytau_lib::player::mpv_render::MpvRenderContext"]],["impl !Sync for SurfaceState",1,1,["jellytau_lib::player::video_surface::SurfaceState"]],["impl !Sync for VideoSurface",1,1,["jellytau_lib::player::video_surface::VideoSurface"]],["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 Phase",0,1,["jellytau_lib::player::media_player::Phase"]],["impl Sync for Output",0,1,["jellytau_lib::player::mpv_player::Output"]],["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 PlaybackKind",0,1,["jellytau_lib::repository::stream_selection::PlaybackKind"]],["impl Sync for Transport",0,1,["jellytau_lib::repository::stream_selection::Transport"]],["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 Capabilities",0,1,["jellytau_lib::player::media_player::Capabilities"]],["impl Sync for OpenRequest",0,1,["jellytau_lib::player::media_player::OpenRequest"]],["impl Sync for PlaybackSnapshot",0,1,["jellytau_lib::player::media_player::PlaybackSnapshot"]],["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 MpvPlayer",0,1,["jellytau_lib::player::mpv_player::MpvPlayer"]],["impl Sync for Shared",0,1,["jellytau_lib::player::mpv_player::Shared"]],["impl Sync for Gl",0,1,["jellytau_lib::player::mpv_render::Gl"]],["impl Sync for Target",0,1,["jellytau_lib::player::mpv_render::Target"]],["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 DeviceProfile",0,1,["jellytau_lib::repository::online::DeviceProfile"]],["impl Sync for DirectPlayProfile",0,1,["jellytau_lib::repository::online::DirectPlayProfile"]],["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 NegotiatedSource",0,1,["jellytau_lib::repository::online::NegotiatedSource"]],["impl Sync for NegotiatedStream",0,1,["jellytau_lib::repository::online::NegotiatedStream"]],["impl Sync for OnlineRepository",0,1,["jellytau_lib::repository::online::OnlineRepository"]],["impl Sync for PlaybackInfoRequest",0,1,["jellytau_lib::repository::online::PlaybackInfoRequest"]],["impl Sync for PlaybackInfoResponse",0,1,["jellytau_lib::repository::online::PlaybackInfoResponse"]],["impl Sync for PlaylistItemsResponse",0,1,["jellytau_lib::repository::online::PlaylistItemsResponse"]],["impl Sync for SubtitleProfile",0,1,["jellytau_lib::repository::online::SubtitleProfile"]],["impl Sync for TranscodingProfile",0,1,["jellytau_lib::repository::online::TranscodingProfile"]],["impl Sync for QualityOption",0,1,["jellytau_lib::repository::stream_selection::QualityOption"]],["impl Sync for Rendition",0,1,["jellytau_lib::repository::stream_selection::Rendition"]],["impl Sync for StreamSelection",0,1,["jellytau_lib::repository::stream_selection::StreamSelection"]],["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"]],["impl<B> Sync for LegacyPlayer<B>",0,1,["jellytau_lib::player::legacy_player::LegacyPlayer"]]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[91630]} \ 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..050d704d --- /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 Phase",0,1,["jellytau_lib::player::media_player::Phase"]],["impl Unpin for Output",0,1,["jellytau_lib::player::mpv_player::Output"]],["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 PlaybackKind",0,1,["jellytau_lib::repository::stream_selection::PlaybackKind"]],["impl Unpin for Transport",0,1,["jellytau_lib::repository::stream_selection::Transport"]],["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 Capabilities",0,1,["jellytau_lib::player::media_player::Capabilities"]],["impl Unpin for OpenRequest",0,1,["jellytau_lib::player::media_player::OpenRequest"]],["impl Unpin for PlaybackSnapshot",0,1,["jellytau_lib::player::media_player::PlaybackSnapshot"]],["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 MpvPlayer",0,1,["jellytau_lib::player::mpv_player::MpvPlayer"]],["impl Unpin for Shared",0,1,["jellytau_lib::player::mpv_player::Shared"]],["impl Unpin for Gl",0,1,["jellytau_lib::player::mpv_render::Gl"]],["impl Unpin for MpvRenderContext",0,1,["jellytau_lib::player::mpv_render::MpvRenderContext"]],["impl Unpin for Target",0,1,["jellytau_lib::player::mpv_render::Target"]],["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 SurfaceState",0,1,["jellytau_lib::player::video_surface::SurfaceState"]],["impl Unpin for VideoSurface",0,1,["jellytau_lib::player::video_surface::VideoSurface"]],["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 DeviceProfile",0,1,["jellytau_lib::repository::online::DeviceProfile"]],["impl Unpin for DirectPlayProfile",0,1,["jellytau_lib::repository::online::DirectPlayProfile"]],["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 NegotiatedSource",0,1,["jellytau_lib::repository::online::NegotiatedSource"]],["impl Unpin for NegotiatedStream",0,1,["jellytau_lib::repository::online::NegotiatedStream"]],["impl Unpin for OnlineRepository",0,1,["jellytau_lib::repository::online::OnlineRepository"]],["impl Unpin for PlaybackInfoRequest",0,1,["jellytau_lib::repository::online::PlaybackInfoRequest"]],["impl Unpin for PlaybackInfoResponse",0,1,["jellytau_lib::repository::online::PlaybackInfoResponse"]],["impl Unpin for PlaylistItemsResponse",0,1,["jellytau_lib::repository::online::PlaylistItemsResponse"]],["impl Unpin for SubtitleProfile",0,1,["jellytau_lib::repository::online::SubtitleProfile"]],["impl Unpin for TranscodingProfile",0,1,["jellytau_lib::repository::online::TranscodingProfile"]],["impl Unpin for QualityOption",0,1,["jellytau_lib::repository::stream_selection::QualityOption"]],["impl Unpin for Rendition",0,1,["jellytau_lib::repository::stream_selection::Rendition"]],["impl Unpin for StreamSelection",0,1,["jellytau_lib::repository::stream_selection::StreamSelection"]],["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"]],["impl<B> Unpin for LegacyPlayer<B>
where\n B: Unpin,
",0,1,["jellytau_lib::player::legacy_player::LegacyPlayer"]]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[92541]} \ 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..f6c05569 --- /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 Phase",0,1,["jellytau_lib::player::media_player::Phase"]],["impl UnsafeUnpin for Output",0,1,["jellytau_lib::player::mpv_player::Output"]],["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 PlaybackKind",0,1,["jellytau_lib::repository::stream_selection::PlaybackKind"]],["impl UnsafeUnpin for Transport",0,1,["jellytau_lib::repository::stream_selection::Transport"]],["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 Capabilities",0,1,["jellytau_lib::player::media_player::Capabilities"]],["impl UnsafeUnpin for OpenRequest",0,1,["jellytau_lib::player::media_player::OpenRequest"]],["impl UnsafeUnpin for PlaybackSnapshot",0,1,["jellytau_lib::player::media_player::PlaybackSnapshot"]],["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 MpvPlayer",0,1,["jellytau_lib::player::mpv_player::MpvPlayer"]],["impl UnsafeUnpin for Shared",0,1,["jellytau_lib::player::mpv_player::Shared"]],["impl UnsafeUnpin for Gl",0,1,["jellytau_lib::player::mpv_render::Gl"]],["impl UnsafeUnpin for MpvRenderContext",0,1,["jellytau_lib::player::mpv_render::MpvRenderContext"]],["impl UnsafeUnpin for Target",0,1,["jellytau_lib::player::mpv_render::Target"]],["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 SurfaceState",0,1,["jellytau_lib::player::video_surface::SurfaceState"]],["impl UnsafeUnpin for VideoSurface",0,1,["jellytau_lib::player::video_surface::VideoSurface"]],["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 DeviceProfile",0,1,["jellytau_lib::repository::online::DeviceProfile"]],["impl UnsafeUnpin for DirectPlayProfile",0,1,["jellytau_lib::repository::online::DirectPlayProfile"]],["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 NegotiatedSource",0,1,["jellytau_lib::repository::online::NegotiatedSource"]],["impl UnsafeUnpin for NegotiatedStream",0,1,["jellytau_lib::repository::online::NegotiatedStream"]],["impl UnsafeUnpin for OnlineRepository",0,1,["jellytau_lib::repository::online::OnlineRepository"]],["impl UnsafeUnpin for PlaybackInfoRequest",0,1,["jellytau_lib::repository::online::PlaybackInfoRequest"]],["impl UnsafeUnpin for PlaybackInfoResponse",0,1,["jellytau_lib::repository::online::PlaybackInfoResponse"]],["impl UnsafeUnpin for PlaylistItemsResponse",0,1,["jellytau_lib::repository::online::PlaylistItemsResponse"]],["impl UnsafeUnpin for SubtitleProfile",0,1,["jellytau_lib::repository::online::SubtitleProfile"]],["impl UnsafeUnpin for TranscodingProfile",0,1,["jellytau_lib::repository::online::TranscodingProfile"]],["impl UnsafeUnpin for QualityOption",0,1,["jellytau_lib::repository::stream_selection::QualityOption"]],["impl UnsafeUnpin for Rendition",0,1,["jellytau_lib::repository::stream_selection::Rendition"]],["impl UnsafeUnpin for StreamSelection",0,1,["jellytau_lib::repository::stream_selection::StreamSelection"]],["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"]],["impl<B> UnsafeUnpin for LegacyPlayer<B>
where\n B: UnsafeUnpin,
",0,1,["jellytau_lib::player::legacy_player::LegacyPlayer"]]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[96987]} \ 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..0f6510ad --- /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 MpvRenderContext",0],["impl Drop for VideoSurface",0],["impl Drop for SessionPollerManager",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[1648]} \ 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..55a30373 --- /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 VideoSurface",1,1,["jellytau_lib::player::video_surface::VideoSurface"]],["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 Phase",0,1,["jellytau_lib::player::media_player::Phase"]],["impl RefUnwindSafe for Output",0,1,["jellytau_lib::player::mpv_player::Output"]],["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 PlaybackKind",0,1,["jellytau_lib::repository::stream_selection::PlaybackKind"]],["impl RefUnwindSafe for Transport",0,1,["jellytau_lib::repository::stream_selection::Transport"]],["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 Capabilities",0,1,["jellytau_lib::player::media_player::Capabilities"]],["impl RefUnwindSafe for OpenRequest",0,1,["jellytau_lib::player::media_player::OpenRequest"]],["impl RefUnwindSafe for PlaybackSnapshot",0,1,["jellytau_lib::player::media_player::PlaybackSnapshot"]],["impl RefUnwindSafe for InternalState",0,1,["jellytau_lib::player::mpv_backend::InternalState"]],["impl RefUnwindSafe for MpvPlayer",0,1,["jellytau_lib::player::mpv_player::MpvPlayer"]],["impl RefUnwindSafe for Shared",0,1,["jellytau_lib::player::mpv_player::Shared"]],["impl RefUnwindSafe for Gl",0,1,["jellytau_lib::player::mpv_render::Gl"]],["impl RefUnwindSafe for MpvRenderContext",0,1,["jellytau_lib::player::mpv_render::MpvRenderContext"]],["impl RefUnwindSafe for Target",0,1,["jellytau_lib::player::mpv_render::Target"]],["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 SurfaceState",0,1,["jellytau_lib::player::video_surface::SurfaceState"]],["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 DeviceProfile",0,1,["jellytau_lib::repository::online::DeviceProfile"]],["impl RefUnwindSafe for DirectPlayProfile",0,1,["jellytau_lib::repository::online::DirectPlayProfile"]],["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 NegotiatedSource",0,1,["jellytau_lib::repository::online::NegotiatedSource"]],["impl RefUnwindSafe for NegotiatedStream",0,1,["jellytau_lib::repository::online::NegotiatedStream"]],["impl RefUnwindSafe for PlaybackInfoRequest",0,1,["jellytau_lib::repository::online::PlaybackInfoRequest"]],["impl RefUnwindSafe for PlaybackInfoResponse",0,1,["jellytau_lib::repository::online::PlaybackInfoResponse"]],["impl RefUnwindSafe for PlaylistItemsResponse",0,1,["jellytau_lib::repository::online::PlaylistItemsResponse"]],["impl RefUnwindSafe for SubtitleProfile",0,1,["jellytau_lib::repository::online::SubtitleProfile"]],["impl RefUnwindSafe for TranscodingProfile",0,1,["jellytau_lib::repository::online::TranscodingProfile"]],["impl RefUnwindSafe for QualityOption",0,1,["jellytau_lib::repository::stream_selection::QualityOption"]],["impl RefUnwindSafe for Rendition",0,1,["jellytau_lib::repository::stream_selection::Rendition"]],["impl RefUnwindSafe for StreamSelection",0,1,["jellytau_lib::repository::stream_selection::StreamSelection"]],["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"]],["impl<B> RefUnwindSafe for LegacyPlayer<B>
where\n B: RefUnwindSafe,
",0,1,["jellytau_lib::player::legacy_player::LegacyPlayer"]]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[104179]} \ 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..d36e6390 --- /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 VideoSurface",1,1,["jellytau_lib::player::video_surface::VideoSurface"]],["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 Phase",0,1,["jellytau_lib::player::media_player::Phase"]],["impl UnwindSafe for Output",0,1,["jellytau_lib::player::mpv_player::Output"]],["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 PlaybackKind",0,1,["jellytau_lib::repository::stream_selection::PlaybackKind"]],["impl UnwindSafe for Transport",0,1,["jellytau_lib::repository::stream_selection::Transport"]],["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 Capabilities",0,1,["jellytau_lib::player::media_player::Capabilities"]],["impl UnwindSafe for OpenRequest",0,1,["jellytau_lib::player::media_player::OpenRequest"]],["impl UnwindSafe for PlaybackSnapshot",0,1,["jellytau_lib::player::media_player::PlaybackSnapshot"]],["impl UnwindSafe for InternalState",0,1,["jellytau_lib::player::mpv_backend::InternalState"]],["impl UnwindSafe for MpvPlayer",0,1,["jellytau_lib::player::mpv_player::MpvPlayer"]],["impl UnwindSafe for Shared",0,1,["jellytau_lib::player::mpv_player::Shared"]],["impl UnwindSafe for Gl",0,1,["jellytau_lib::player::mpv_render::Gl"]],["impl UnwindSafe for MpvRenderContext",0,1,["jellytau_lib::player::mpv_render::MpvRenderContext"]],["impl UnwindSafe for Target",0,1,["jellytau_lib::player::mpv_render::Target"]],["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 SurfaceState",0,1,["jellytau_lib::player::video_surface::SurfaceState"]],["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 DeviceProfile",0,1,["jellytau_lib::repository::online::DeviceProfile"]],["impl UnwindSafe for DirectPlayProfile",0,1,["jellytau_lib::repository::online::DirectPlayProfile"]],["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 NegotiatedSource",0,1,["jellytau_lib::repository::online::NegotiatedSource"]],["impl UnwindSafe for NegotiatedStream",0,1,["jellytau_lib::repository::online::NegotiatedStream"]],["impl UnwindSafe for PlaybackInfoRequest",0,1,["jellytau_lib::repository::online::PlaybackInfoRequest"]],["impl UnwindSafe for PlaybackInfoResponse",0,1,["jellytau_lib::repository::online::PlaybackInfoResponse"]],["impl UnwindSafe for PlaylistItemsResponse",0,1,["jellytau_lib::repository::online::PlaylistItemsResponse"]],["impl UnwindSafe for SubtitleProfile",0,1,["jellytau_lib::repository::online::SubtitleProfile"]],["impl UnwindSafe for TranscodingProfile",0,1,["jellytau_lib::repository::online::TranscodingProfile"]],["impl UnwindSafe for QualityOption",0,1,["jellytau_lib::repository::stream_selection::QualityOption"]],["impl UnwindSafe for Rendition",0,1,["jellytau_lib::repository::stream_selection::Rendition"]],["impl UnwindSafe for StreamSelection",0,1,["jellytau_lib::repository::stream_selection::StreamSelection"]],["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"]],["impl<B> UnwindSafe for LegacyPlayer<B>
where\n B: UnwindSafe,
",0,1,["jellytau_lib::player::legacy_player::LegacyPlayer"]]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[101956]} \ 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/media_player/trait.MediaPlayer.js b/api/trait.impl/jellytau_lib/player/media_player/trait.MediaPlayer.js new file mode 100644 index 00000000..d4ab97f1 --- /dev/null +++ b/api/trait.impl/jellytau_lib/player/media_player/trait.MediaPlayer.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..69c6e221 --- /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 PlaybackKind",0],["impl<'de> Deserialize<'de> for Transport",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 NegotiatedSource",0],["impl<'de> Deserialize<'de> for NegotiatedStream",0],["impl<'de> Deserialize<'de> for PlaybackInfoResponse",0],["impl<'de> Deserialize<'de> for PlaylistItemsResponse",0],["impl<'de> Deserialize<'de> for QualityOption",0],["impl<'de> Deserialize<'de> for Rendition",0],["impl<'de> Deserialize<'de> for StreamSelection",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":[43699]} \ 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..2a270613 --- /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 PlaybackKind",0],["impl Serialize for Transport",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 DeviceProfile",0],["impl Serialize for DirectPlayProfile",0],["impl Serialize for JRayActor",0],["impl Serialize for PlaybackInfoRequest",0],["impl Serialize for SubtitleProfile",0],["impl Serialize for TranscodingProfile",0],["impl Serialize for QualityOption",0],["impl Serialize for Rendition",0],["impl Serialize for StreamSelection",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":[40252]} \ 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..06184990 --- /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 PlaybackKind",0],["impl Flatten for Transport",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 QualityOption",0],["impl Flatten for Rendition",0],["impl Flatten for StreamSelection",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":[21268]} \ 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..729471f8 --- /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 PlaybackKind",0],["impl NamedType for Transport",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 QualityOption",0],["impl NamedType for Rendition",0],["impl NamedType for StreamSelection",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":[23745]} \ 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..d92416d4 --- /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 PlaybackKind",0],["impl Type for Transport",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 QualityOption",0],["impl Type for Rendition",0],["impl Type for StreamSelection",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":[23130]} \ 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..f0d29117 --- /dev/null +++ b/architecture/01-rust-backend.html @@ -0,0 +1,1005 @@ + + + + + + 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.

+

Two levels of ceiling

+

Location: src-tauri/src/repository/online.rs (TRACES: UR-074, UR-079 | DR-226)

+

There are two, and they are not the same thing:

+
+ + +
Set byLives untilRead via
Device defaultSettings (player_set_video_settings)Persisted; restored at startupstreaming_quality()
Per-playback overrideThe in-player picker (player_set_stream_quality)The next item starts playingplayback_quality_override()
+
+

effective_streaming_quality() resolves the pair — override first, else default — +and is the only thing stream construction may read. Every URL builder and the +PlaybackInfo negotiation go through it, for the reason the process-wide static +existed in the first place: if the negotiation and the URL builder disagree, the +cap leaks — the negotiation authorises a direct play the builder then never gets +to constrain, or the reverse.

+
+

The override exists because a single global cannot express "this 4K remux needs +a ceiling, that podcast does not". The picker had documented itself as a "this +film, this connection" control since it was written, but was implemented by +writing the default — so dropping one awkward film to 2 Mbps silently capped +every video played afterwards for the rest of the process, with Settings still +showing the old value. It is cleared on every player_play_item / +player_play_queue / player_play_tracks, which is what stops it surviving +into an autoplayed next episode where nobody would reopen the picker.

+
+

Stream selection

+

Location: src-tauri/src/repository/stream_selection.rs, +OnlineRepository::get_stream_selection (TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228)

+

Rust decides what stream. The player decides how to deliver it. That line +is the whole design. A backend with genuine adaptive selection (ExoPlayer over a +multi-variant playlist) is left to do it; Rust chooses what to request and never +paces bytes.

+

get_stream_selection returns one self-describing StreamSelection in place of +the bare URL get_video_stream_url used to hand out:

+
+ + + + + + +
FieldCarries
urlWhat to open
transportHls / Progressive / LocalFile — how to fetch it
playback_kindDirectPlay / DirectStream / Transcode — what the server is doing to the source
renditionThe negotiated ceiling and codecs; None for a direct play, which is the source
availableThe quality ladder as it applies to this media source (DR-227)
needs_transcodingDerived from playback_kind, so the rule is answered once
+
+

Both enums are serde-tagged ({"type":"hls"}) so the frontend matches a +discriminant rather than comparing text.

+
+

Why transport exists. VideoPlayer.svelte chose its loader with +url.includes(".m3u8"), in two places. Rust built that URL and knows exactly +what it is; re-deriving it downstream by substring match is a domain fact +reconstructed in the presentation layer — the same class of error as leaking +item-type taxonomy, and one that fails silently in both directions: a +progressive file served from a path containing the substring gets an HLS +loader, and a playlist served from a path without it does not.

+

The paths that never negotiate get the same shape from Rust rather than letting +a caller assemble one — media_local_selection for a downloaded file, +LiveStreamInfo.transport for a live channel — so there is no second place +where a transport is decided.

+
+

The playback-kind decision

+

decide_playback_kind is a free function and pure, so every branch is testable +from PlaybackInfo fixtures without a server. Order matters — the two +client-side overrides come first, because each describes a case where the +server's answer is right about the file and wrong about what this app will do +with it:

+
    +
  1. Undecodable audio → Transcode. Jellyfin 10.11.5 honours a +DirectPlayProfile's container and video codec but ignores its audio codec, +so it offers direct play for an E-AC-3 track the webview renders in silence. +A silent direct play is worse than a transcode.
  2. +
  3. A pinned audio track → Transcode. Not a defect in the server's answer, a +different question: the file has one default track and the viewer asked for +another.
  4. +
  5. Otherwise supports_direct_playDirectPlay, else supports_direct_stream +→ DirectStream, else Transcode.
  6. +
+

A direct stream is a remux — codecs copied, container repackaged. It is cheap +and is deliberately not counted as transcoding; conflating the two would report +a free passthrough as a server-side re-encode.

+
+

What this is worth, measured. Against the development server (Jellyfin +10.11.5), 400 items sampled for codec mix and 40 put through a real negotiation +per profile:

+
+ + +
ProfileDirect play
Linux / WebKitGTK (h264 only, 2ch)3/40 — 7%
Android / ExoPlayer (h264,hevc,vp8,vp9,av1,mpeg4 + ac3,eac3, 6ch)34/40 — 85%
+
+

The library is ~80% hevc (hevc+eac3 alone is a third of it), which is why the +two diverge so hard.

+

Read that 85% as a ceiling, not a result. It was measured with a profile +containing ac3,eac3. The Android device this was later run on reports neither +in its MediaCodecList — no Dolby licence, which is normal for a tablet — so +eac3 content, about a third of the sampled library, correctly transcodes there. +What any given device achieves depends on its own codec list, and on the +profile being derived from the renderer at all (DR-234), which it was not when +the figure was taken.

+

The payoff is still overwhelmingly Android, because that is where a real +decoder is already doing the work. Linux stays near 7% until libmpv decodes the +picture — the h264-only profile is a WebKitGTK constraint, not a JellyTau +choice, and is what linux-native-video-spike.md exists to remove. A reviewer +should not expect this code to fix Linux on its own.

+
+

The quality ladder per source

+

quality_options_for_source(source_bitrate) returns every rung, each marked with +exceeds_source: true when that rung's ceiling is at or above what the source +itself carries, so selecting it produces the same bytes as Original. The +frontend draws the list and drops the redundant rungs; it does not decide which +they are.

+
    +
  • Original is never marked — it is the source.
  • +
  • An unreported source bitrate (some containers have none; the sampled library +has avi files with no bitrate at all) marks nothing redundant, keeping +every rung offered. That is the safe direction: the viewer keeps every choice.
  • +
+

No adaptive ladder to preserve

+

TRACES: UR-079 | DR-229 (Won't Do)

+

Mid-playback re-negotiation on throughput was scoped and dropped on measurement. +A master playlist from this server carries exactly one EXT-X-STREAM-INF: +Jellyfin builds it from the single rendition the request asked for rather than +publishing a ladder. So there is no adaptation for hls.js to be preserving and +none that mpv would lose — the claim that there was is recorded in +playback-backend-unification.md and does not hold. "Adapt mid-stream" collapses +into "pick well at open", which is what the two levels of ceiling and the +per-source ladder already are.

+

Kept here because it is a measurement, not an opinion: a server that does +publish a ladder would change the answer, and the re-negotiation path below is +the hook that work would build on.

+

Re-negotiation

+

One mechanism, not two. player_seek_video, player_switch_audio_track and +player_set_stream_quality all return a tagged strategy saying who reloads — +the backend handles a native backend itself and hands the webview a +StreamSelection for reloadSource. Note the wire wart: tauri-specta keeps +these response fields snake_case (seek_offset), while the strategy tag itself +is camelCase.

+

The frontend names a variant and nothing else; the labels the picker shows are +served over IPC — from available on the selection, or +player_get_streaming_qualities for the Settings list.

+

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..c0c3a28a --- /dev/null +++ b/architecture/02-svelte-frontend.html @@ -0,0 +1,1030 @@ + + + + + + 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.

+

Stream Transport

+

Location: src/lib/player/streamTransport.ts +TRACES: UR-079 | DR-225 | UT-214

+

videoLoaderFor(selection, capabilities) picks the loader for the webview +<video> element — hlsjs, nativeHls, or direct — from the backend's tagged +selection.transport. elementSrcFor is its template companion: the element's +src is emptied only when hls.js is driving it.

+

The split is the point. The transport is the stream's property and comes from +Rust; whether a given loader exists is the browser's, and is the only thing +decided here.

+
+

This replaced currentStreamUrl.includes(".m3u8"), which appeared twice in +VideoPlayer.svelte — once in the HLS $effect and once inline in the +template's src. Rust builds that URL and knows what it is; re-deriving it +here by substring match was a domain fact reconstructed in the presentation +layer, and it fails silently in both directions. The two tests that pin it are +the ones that failed against the old implementation: a progressive stream +whose URL contains .m3u8 must not get an HLS loader, and an hls stream +whose URL contains no .m3u8 must.

+

Logic lives in a plain .ts module rather than in the component for the usual +reason — it is testable there. Same pattern as episodeStrip.ts.

+
+

VideoPlayer holds a currentSelection, not a URL string; currentStreamUrl is +derived from it. A reload replaces the selection wholesale (the adapter's +bridge takes a StreamSelection, not a URL), so transport and URL can never +drift apart. The background-audio handoff states the transport it is moving to — +progressive mp3 out, HLS back — via selectionAt(), rather than leaving it to be +inferred.

+

The quality picker is filled from selection.available (DR-227): rungs the +backend marked exceedsSource are not drawn, because they produce the same bytes +as Original. Nothing is optimistically assigned when the viewer picks a rung — +what the menu shows comes from the selection the backend hands back, since a +ceiling above the source bitrate is the source.

+

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..3d2fe39a --- /dev/null +++ b/architecture/03-data-flow.html @@ -0,0 +1,477 @@ + + + + + + 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
+
+

Video Stream Selection Flow

+

TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228

+

Before a video plays, Rust decides what stream — direct play, remux or +transcode, over which transport — and hands the player one self-describing +StreamSelection. The page no longer inspects the URL to work any of this out.

+
sequenceDiagram
+    participant Page as player/[id]/+page.svelte
+    participant Repo as HybridRepository
+    participant Online as OnlineRepository
+    participant Server as Jellyfin
+    participant VP as VideoPlayer.svelte
+
+    Page->>Repo: playerLocalMediaPath(id)
+    alt a completed download exists
+        Page->>Repo: mediaLocalSelection(path)
+        Note over Page: LocalFile / DirectPlay, no ladder —<br/>nothing about a file on disk re-negotiates
+    else stream from the server
+        Page->>Repo: getStreamSelection(id, mediaSourceId)
+        Repo->>Online: get_stream_selection()
+        Online->>Online: effective_streaming_quality()
+        Note over Online: per-playback override, else device default
+        Online->>Server: POST /Items/{id}/PlaybackInfo<br/>(device profile + ceiling)
+        Server-->>Online: MediaSource {supportsDirectPlay,<br/>supportsDirectStream, transcodingUrl, bitrate}
+        Online->>Online: decide_playback_kind()
+        alt Transcode
+            Online->>Online: adopt/stop prior play session,<br/>build HLS URL
+            Note over Online: Transport::Hls
+        else DirectPlay / DirectStream
+            Online->>Online: /Videos/{id}/stream?static=true
+            Note over Online: Transport::Progressive,<br/>rendition = None (it IS the source)
+        end
+        Online->>Online: quality_options_for_source(bitrate)
+        Online-->>Page: StreamSelection
+    end
+    Page->>VP: selection
+    VP->>VP: videoLoaderFor(selection, caps)
+    Note over VP: hls.js / native HLS / direct —<br/>from the tag, never from the URL
+
+

The selection travels with the stream from then on. A reload — a quality change, +an audio-track switch, a transcoded seek — returns a new selection through the +same tagged strategy response, so transport and URL can never disagree; and the +queue item carries the transport so player_seek_video picks its seek strategy +from the backend's decision rather than from the URL string.

+

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..c793381a --- /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..e82d92fe --- /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..33ebc706 --- /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..9831eea1 --- /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..f99e5255 --- /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..cf566121 --- /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..04cf22e1 --- /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..f7830467 --- /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..5a8e0d4e --- /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..0e27992c --- /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..7fdde367 --- /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..fcb8c153 --- /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..f0edeb3c --- /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..19e60f40 --- /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 + + + + + Native Player Verification - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Native player — verification plan

+

What to check before the MediaPlayer contract and Linux native video reach +master.

+

This is not a generic smoke test. Every case below exists because something +specific went wrong, and most of them were found on hardware after the +automated suites were green. Treat the sequences as load-bearing: several +defects only appeared in a particular order of actions, and testing the same +features in a different order missed them entirely.

+

Companion to release-checklist.md, which covers the +release mechanics. This covers whether the player is fit to release at all.

+

What is risky about this change

+
    +
  • PlayerController now talks to a MediaPlayer contract instead of +PlayerBackend. Every engine reaches it through an adapter that did not exist +before (DR-245).
  • +
  • mpv decodes video on Linux for the first time, composited under the webview +(DR-231).
  • +
  • Seek strategy is driven by an ability each engine declares rather than by a +truth table (DR-246).
  • +
  • Two regressions were introduced during this work and caught only on a device: +a wrong capability for ExoPlayer (DR-246 follow-up) and a Duration panic +(DR-252). Both were invisible to the test suites.
  • +
+

The suites originally verified only engines that behave, which is why both +regressions passed them. That gap is now partly closed in code rather than in +this document: UT-223 drives a deliberately hostile engine — C.TIME_UNSET, +NaN, infinities, negatives — through the adapter, and fails with the exact +panic that produced a black screen on a tablet. UT-224 pins the handoff +clearing that was previously verified by listening to a device.

+

Prefer moving cases out of this file and into tests. Anything here that +could fail automatically should; a checklist depends on someone remembering to +follow it, and the two defects it was written for cost hardware time that would +have been better spent making the suites realistic. What is left below is what +genuinely needs eyes, ears, or a display — not what merely has not been +automated yet.

+

1. Automated gates

+

Cheap, fast, and non-negotiable. Run from the worktree.

+
bun run check                      # 0 errors, 0 warnings
+bun run test                       # frontend
+bun run test:rust                  # Rust
+bun run format:check
+bun run lint                       # 0 errors; warnings at or below the CI ratchet
+bun run check:boundary
+bun run traces:validate
+bun run traces:coverage            # at or above MIN_THRESHOLD
+cd src-tauri && cargo fmt --check && cargo clippy --all-targets -- -D warnings
+cargo clippy --all-targets --features conformance -- -D warnings
+
+

The eslint warning count is a ratchet: equal to the CI limit is a pass, one +over fails the build. Going one over is how a piece of dead state was found +during this work — do not raise the limit to get past it.

+

2. Engine conformance

+
bun run test:player                # mpv + legacy, desktop
+bun run test:player:android        # ExoPlayer, on a connected device
+
+

Expected, and each deviation is meaningful rather than noise:

+
+ + + +
EngineResultIf it differs
MpvPlayer9/9A real regression. Stop.
LegacyPlayer8/9The one failure is transport_settings_round_trip: the old trait has no mute or rate. Any other failure is a regression.
ExoPlayer (device)7/7Two cases are absent because the Kotlin player exposes no mute or rate.
+
+

A green conformance run is not sufficient evidence to ship. Both regressions +introduced during this work passed conformance.

+

3. Desktop (Linux)

+

Run with native video on, since that is what is new:

+
JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
+
+
    +
  • +Direct play — a file the server does not transcode. Picture and sound.
  • +
  • +Transcoded play — something the server must re-encode (4K, HEVC, or an +audio codec the renderer cannot take).
  • +
  • +Resume — an item watched previously on this install. The prompt +appears and playback starts at the offered position, not at zero. +(Resume is device-local — see "Known open".)
  • +
  • +Scrub on a direct-play item; position lands and playback continues.
  • +
  • +Scrub on a transcoded item. Separate case on purpose: it takes a +different path, and it silently did nothing for months (DR-238).
  • +
  • +Pause and resume — the button follows the player. It stopped doing so +when a property was handled but never observed (DR-239).
  • +
  • +Fullscreen — the window really fills the display. Measure it if +unsure: the log prints rendering WxH, and a height short of the panel +means the document went fullscreen and the window did not (DR-240).
  • +
  • +Exit the player — audio stops. Listen; do not assume.
  • +
  • +Audio-only playback still works: mini player, queue, next/previous.
  • +
  • +Nothing in the log matches PANIC or ERROR.
  • +
+

4. Android

+

The tablet needs the side-by-side build. Do not uninstall the release app +to make an install succeed — see "Known open" for why the normal command is +currently wrong.

+
bun run android:build --device
+./scripts/sync-android-sources.sh
+cd src-tauri/gen/android && ANDROID_HOME="$HOME/Android/Sdk" ./gradlew \
+  :app:assembleUniversalDebug -x :app:rustBuildUniversalDebug \
+  -x :app:rustBuildArm64Debug -x :app:rustBuildArmDebug \
+  -x :app:rustBuildX86Debug -x :app:rustBuildX86_64Debug
+adb install -r app/build/outputs/apk/universal/debug/app-universal-debug.apk
+
+

Confirm the package is com.dtourolle.jellytau.debug before installing:

+
aapt2 dump packagename <apk>
+
+

If it says com.dtourolle.jellytau, the suffix was lost — stop, re-sync and +re-assemble. Installing it would try to replace the real app.

+

Then, with adb logcat capturing:

+
    +
  • +

    +Play a video. Picture, sound, and controls.

    +
  • +
  • +

    +Scrub. The bar has a scale — a duration of 0.0 means the seek bar has +nothing to scrub against (DR-251).

    +
  • +
  • +

    +Transcoded seek lands rather than restarting the stream. ExoPlayer seeks a +transcode in place; declaring otherwise re-opened it (DR-246).

    +
  • +
  • +

    +PiP.

    +
  • +
  • +

    +Lockscreen: controls respond and position tracks.

    +
  • +
  • +

    +The handoff sequence, in this exact order: +1. play a video +2. enable background audio +3. background the app — audio continues +4. foreground the app — video returns +5. exit the player — everything stops

    +
    Steps 4 and 5 are where two separate defects lived (DR-250, DR-252). Doing
    +the same actions in another order finds neither.
    +
    +
  • +
  • +

    +grep -c 'PANIC at' <logcat> returns 0.

    +
  • +
+

5. Regression checks with a named cause

+

Each of these presented as something other than its cause, which is why they are +listed separately from the feature passes above.

+
+ + + + + + + +
Symptom to look forWas actuallyRef
Skip on a transcoded item does nothing, or jumps to zeroSeek strategy keyed on the container, not the engineDR-238, DR-246
Play/pause button does not follow the playerA property handled but never observed, so the event never arrivedDR-239
Fullscreen leaves a strip of desktopThe document went fullscreen, the window did notDR-240
Resume plays from the beginningA seek issued before the engine had a file was discardedDR-241
Scrub bar has no scaleDuration reported as 0.0 and believedDR-251
Black screen, no controls, after a background-audio round tripA junk duration converted to a Duration panicked the backendDR-252
Audio still playing after leaving the playerThe stop was aimed at whichever renderer bookkeeping believed was activeDR-250
+
+

Known open — decide, do not discover

+

None of these are fixed. Each needs an explicit ship / do-not-ship call rather +than being met with surprise during testing.

+
    +
  • Resume is device-local. Progress is read from the local database and +nothing consults the server's UserData. A fresh install, a second device or +a reinstall offers no resume even though the server knows the position. Not a +regression — it has always been so.
  • +
  • The background-audio handoff is an unconfirmed state swap. +exit_background_audio marks the video element the player again the moment it +is called, while the element has not reloaded. DR-250 makes the visible +symptom impossible; the race is intact and can still misdirect a lockscreen +command or a position read. See +media-player-controller.md.
  • +
  • 🔴 The side-by-side debug install is broken. bun run android:dev +produces an APK with the release application id, because the Tauri build +regenerates gen/build.gradle.kts after the sync drops the .debug suffix in. +It then fails on signatures, and its own error message advises uninstalling — +which would destroy the real app's data. Fix this before anyone else builds +for Android.
  • +
  • PlayerBackend still exists behind LegacyPlayer, and the frontend still +carries some playback state. DR-248 and DR-249 are not started.
  • +
+

Ship criteria

+

Ship when:

+
    +
  1. Every automated gate in §1 passes.
  2. +
  3. Conformance matches §2 exactly, deviations included.
  4. +
  5. §3 and §4 are complete, on real hardware, by a person.
  6. +
  7. §5 shows no symptom returning.
  8. +
  9. Every item in "Known open" has a recorded decision.
  10. +
+

Do not ship on green suites alone. Both regressions introduced during this work +passed every suite and were caught by a person using the app.

+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/print.html b/print.html new file mode 100644 index 00000000..a7e997a5 --- /dev/null +++ b/print.html @@ -0,0 +1,35776 @@ + + + + + + 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-079The app decides what stream to play and says so. Playing a video used to mean asking the server to re-encode it, always — a decision made nowhere, written down nowhere, and re-derived downstream by whoever needed it: the player worked out whether it had been handed a playlist by looking for .m3u8 in the URL. So a viewer paid for a transcode of a file their device could have played untouched, and the app could not tell them which it was. Now one negotiation produces one self-describing answer — direct play, remux, or transcode; over a playlist, a plain HTTP file, or a local one — and every renderer consumes that same answer instead of guessing from a string. On Android, where the player decodes almost everything the library holds, this stops around 85% of plays from starting a transcode nobody neededMediumDone
UR-080Video on the desktop plays as itself. The picture was drawn by a webview <video> element, which decodes little beyond h264 — so the app told the server it could accept only h264, and the server re-encoded almost everything before sending it. That was never a statement about the machine: the same machine already runs mpv for audio, which decodes essentially the whole library. Measured against a real library, 93% of desktop playback was a transcode nobody needed, against 15% on Android where a real decoder does the work. mpv now draws the picture, the app claims what it can genuinely decode, and video is sent as it was stored wherever that is possible — sparing the server the work, the network the bitrate, and the picture a generation of re-encodingMediumProposed
UR-081Playback behaves the same whichever engine renders itHighIn Progress
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
IR-033libmpv render-API integration for video: vo=libmpv driving an OpenGL FBO bound by the host toolkit, with GL entry points resolved through libepoxy. Note that libepoxy exports them as data symbols — there is no glFoo function, only an epoxy_glFoo variable holding a lazily-resolving pointer — so 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 on the first GL call. The epoxy crate resolves this correctly but is unusable, its gl_generator dependency pulling a yanked xml-rsPlaybackUR-080Proposed
+
+
+

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-225StreamSelection replaces the bare URL returned for playback: URL, Transport (hls / progressive / localFile), PlaybackKind (directPlay / directStream / transcode), the negotiated Rendition, the ladder this source can offer, and a needs_transcoding flag derived in Rust so "which kinds count as transcoding" is answered once. Both enums are serde-tagged ({"type":"hls"}) so the frontend matches a discriminant rather than comparing text. The field that mattered most is transport: VideoPlayer.svelte chose its loader with url.includes(".m3u8") in two places, a domain fact reconstructed in the presentation layer — the same class of error as leaking item-type taxonomy, and one that fails silently in both directions (a progressive file served from a path containing the substring gets an HLS loader; a playlist served from one without it does not). The paths that never negotiate — a downloaded file, a live channel — get the same shape from Rust (media_local_selection, LiveStreamInfo.transport) rather than having the page assemble one, so there is no second place where a transport is decidedPlaybackUR-079Done
DR-226The bandwidth ceiling is two-level: a durable device default (Settings, persisted, restored at startup) and a per-playback override the in-player picker sets. The picker's own documentation had called it a "this film, this connection" control since it was written, but it was implemented by writing the process-wide default — so dropping one awkward film to 2 Mbps silently capped every video played afterwards for the rest of the process, while the Settings screen still displayed the old value and nothing in the UI admitted the change. The override is cleared whenever playback moves to a new item, which is what keeps it from surviving into an autoplayed next episode where nobody would reopen the picker. effective_streaming_quality() is the single resolution point; every URL builder and the PlaybackInfo negotiation go through it, because a negotiation that authorises a direct play the URL builder then constrains (or the reverse) leaks the capPlaybackUR-074, UR-079Done
DR-227The quality picker is filled from what this media source can offer, not from the fixed eight-rung enum. Rust marks each rung exceeds_source when its ceiling is at or above the source's own bitrate — such a rung produces the same bytes as Original, so offering it is another way to spell one choice — and the frontend simply does not draw those. Original is never marked (it is the source) and a source whose bitrate the server does not report (the sampled library has avi files with none) marks nothing redundant, keeping every rung offered, which is the safe direction. The picker also shows what the server is actually doing with the stream, which only became knowable once PlaybackKind existed. Labels and detail lines come from Rust beside the numbers they describe, so a relabelled rung cannot drift out of step with what it doesUIUR-070, UR-079Done
DR-228Direct play and direct stream are negotiated rather than assumed away. get_video_stream_url always built an HLS transcode URL, so every video play burned server CPU even when the file would have played untouched. The decision now comes from PlaybackInfo under the device profile and the ceiling in force, with two client-side overrides applied on top because the server's answer is right about the file and wrong about what this app will do with it: undecodable audio (Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec but ignores its audio codec, so it offers direct play for an E-AC-3 track the webview renders in silence) and a viewer-pinned audio track the source file does not default to. Measured against the development server over a 400-item sample: 85% direct play on the Android profile, 7% on the Linux one — the library is ~80% hevc and WebKitGTK can only claim h264, so the Linux figure is a property of the renderer, not of this code, and is what linux-native-video-spike.md exists to change. A direct stream is a remux and is deliberately not counted as transcodingPlaybackUR-079Done
DR-229Mid-playback re-negotiation on throughput was scoped and dropped on measurement. The premise — that hls.js gives this app real adaptive bitrate and mpv would lose it — does not hold: a master playlist from the development server carries exactly one EXT-X-STREAM-INF, because Jellyfin builds it from the single rendition the request asked for rather than publishing a ladder. There is no adaptation to preserve, so "adapt mid-stream" collapses into "pick well at open", which is what DR-225 and DR-226 already are. Recorded rather than deleted because the conclusion is a measurement, not an opinion, and a server that does publish a ladder would change it — the DR-224 re-negotiation path is the hook that work would build onPlaybackUR-079Won't Do
DR-230Every player backend consumes the same selection, proving the contract is player-agnostic rather than HTML5-shaped. The queue item carries the negotiated transport, so player_seek_video picks its seek strategy from the backend's own decision instead of the last stream_url.contains(".m3u8") in the codebase; items queued by a path that never negotiated (audio tracks, direct URLs) carry None and fall back to needs_transcoding, which is exact rather than a guess because every transcode this app requests is HLS (DR-140). The webview adapter's bridge carries the whole selection rather than a URL, so the component's HLS effect reads a tag instead of searching a string, and the background-audio handoff states the transport it is moving to (progressive mp3 out, HLS back) rather than leaving it to be inferredPlaybackUR-003, UR-004, UR-079Done
DR-231An mpv video backend that composites beneath the transparent webview, the desktop counterpart of the Android TextureView arrangement. mpv renders through its render API into an FBO the toolkit binds (vo=libmpv + mpv_render_context_create with MPV_RENDER_PARAM_OPENGL_FBO), rather than by embedding a foreign window — which is what the 2024 "not possible on Wayland at all" conclusion was about and why it does not apply. On Linux that is a GtkOverlay with a GtkGLArea as main child and Tauri's own webview reparented as the overlay child; the mpv half is shared and only the surface differs per platform. Webview transparency alone suffices — no window-level transparency is used or neededPlaybackUR-080Proposed
DR-232The mpv render context's lifetime is bound to the GL context it draws into: created on realize, freed on unrealize, on the same thread, with the update callback unregistered before the free so a callback cannot land on a freed context. This is DR-184 on Android restated — a surface outliving its player — and it is a requirement in its own right rather than a fix for a specific crash. The spike observed one SIGSEGV in a decoder thread that three targeted soaks failed to reproduce; what is not in doubt is that the spike never called mpv_render_context_free and never tore down on unrealize, so nothing defended against the GL context being recreated underneath. Removing the likeliest cause is worth doing whether or not it was the causePlaybackUR-080Proposed
DR-233Frame pacing goes through mpv's update callback, with mpv_render_context_report_swap after each render. Recorded as a requirement because the failure mode misleads: driving the widget's frame clock every tick without reporting the swap leaves mpv with nothing to time against, which looks fine in a window and judders at fullscreen — reading as a compositing or GPU limit and being neitherPlaybackUR-080Proposed
DR-234The device profile is derived from the renderer that will decode the stream, not from a compile-time platform constant. video_codecs was #[cfg(target_os)], which is correct only while a build has one video renderer; once mpv and the webview element coexist it must be runtime state. This is the change that converts the measured 7% desktop direct-play rate toward the 85% the Android profile achieves on the same library, because the two differ by nothing except which component decodes. It looks like configuration and is not — it is the input that decides whether the server re-encodes, and getting it wrong fails silently, a claimed codec the renderer cannot decode being a black picture or silence (DR-148, and DR-227's audio override). The webview's narrower audio set stops applying to the video path once mpv decodes it, while the multichannel bound still does, since a 5.1 track direct-played into a two-channel sink is silence or inaudible dialogueRepositoryUR-080, UR-070In Progress
DR-235The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do hls.js, html5Adapter.ts, videoLoaderFor and the <video> element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio <audio> path is untouched. With no HTML5 fallback left, a failed mpv init emits backend-init-failed and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying forPlaybackUR-080Proposed
DR-236Hardware-decode policy is decided from what mpv reports it selected (hwdec-current), never from what it was asked for. The spike established that hardware decode works through the render API at all — the load-bearing result, since it means direct play is not bought with software decoding — but also that auto reached for the discrete GPU in copy-back mode on a hybrid Intel+NVIDIA laptop, the least efficient hardware path, and that vaapi fell back to software silently because the libva driver was absent. So zero-copy VA-API on the integrated GPU is preferred where the driver is present, auto is a fallback rather than the default, and a missing driver is detected and logged rather than mistaken for a compositing limitPlaybackUR-080Proposed
DR-237Windows reaches the same mpv path, reusing everything except the surface. The surface is genuinely different code — a native child window beneath a transparent WebView2, not GTK — but the render context, lifetime discipline, frame pacing, device profile and hwdec policy are shared, which is why none of them may be guarded on cfg!(target_os = "linux"). The cost is mostly build, not video: libmpv is currently a Linux-only dependency while Windows is cross-compiled from Linux via x86_64-pc-windows-msvc + cargo-xwin, so a Windows libmpv must reach that cross-build and its DLL must ship in the NSIS bundle, carrying the LGPL obligations DR-216 already records — dynamic linkage, licence text shipped alongside. Windows gains a native audio decoder as a side effect, which is what the long-blocked Windows audio work wants and cannot otherwise havePlaybackUR-080Proposed
DR-238A transcoded seek re-negotiates the stream on every renderer, not just the webview. Jellyfin produces a transcode from StartTimeTicks, so where a seek lands is a property of the request rather than of the stream in hand. determine_video_seek_strategy treated is_hls as a proxy for "seekable in place", which held only because hls.js was always the HLS renderer — it seeks within the VOD playlist it is handed and lets the server catch up. mpv's HLS demuxer cannot make the server transcode from a new offset, so with native video on, every transcoded seek became a backend seek that silently did nothing and presented as "resume does not work". The rule is now written on needs_transcoding with hls.js as the stated exception; all four webview cells are unchangedPlayerUR-040Done
DR-239Properties the mpv event loop handles are registered with observe_property. libmpv delivers PropertyChange only for observed properties, so a match arm for an unobserved one is unreachable code that reads as implemented — the handler is right there. pause was handled and never observed, so StateChanged was never emitted on pause or resume and the play/pause control never moved. It stayed invisible while Linux video played in the webview, because the <video> element's own DOM events drove that control; native video made the UI depend on the event that never camePlayerUR-005Done
DR-240Fullscreen moves whatever actually owns the pixels. requestFullscreen() fullscreens the document, which sufficed while every renderer lived inside it — the HTML5 <video> element is part of the document, so WebKit scaled it and the OS window's real size never mattered. A native surface is drawn behind the webview at window size, so a document-only fullscreen expands the page and leaves the picture where it was; on WebKitGTK the result is a maximised window with decorations still holding a strip of the screen, which reads as "fullscreen is broken" rather than as a windowing problem. Android needed the same rule for the system bars (DR-157); this is its desktop halfPlayerUR-066Done
DR-241A seek issued before MPV has a file to seek in is honoured, not dropped. loadfile returns as soon as the command is queued, so time-pos — a live property of the loaded file — does not resolve yet and setting it fails. The two callers that always hit that window are the ones a viewer notices: resume, and a transcoded seek, both of which re-open the stream and then ask for a position. The failed seek was discarded and the stream played from zero, which reads as "resume is broken" and "I cannot skip". The position is now held and applied by the FileLoaded handler; a seek that lands normally clears any deferred one, so the newer intent winsPlayerUR-040, UR-005Done
DR-242The player contract expresses intent, not device operations. MediaPlayer::open carries the start position, so no caller sequences load-then-seek and none can race an engine's asynchronous load; seek states a destination and leaves in-place-vs-re-open to the engine, which is the only layer that knows its own transport; snapshot is one coherent read; and Phase::Opening names the window a seek used to be lost in. Replaces PlayerBackend, which abstracted a device and required each of the three engines to re-derive the same rulesPlayerUR-081In Progress
DR-243Every engine passes one conformance suite, and a FakePlayer implements the contract deterministically. The suite is written before the second engine so it cannot encode whatever the first happened to do, and it drives readiness through a harness rather than sleeping. FakePlayer models the one behaviour that matters — opening is not instantaneous — so the load/seek race can be expressed on purpose, and lets the controller, queue, autoplay and session logic be tested with no engine at allPlayerUR-081In Progress
DR-244MpvPlayer implements MediaPlayer over libmpv, applying the start position at load time via mpv's own start option rather than seeking after an asynchronous loadfile, and holding a seek that arrives during Opening until the file loads. A standalone player-conformance binary runs the suite against it with audio and video routed to null, so a wrapper is verifiable without building or launching the appPlayerUR-081, UR-040Done
DR-245PlayerController holds a MediaPlayer rather than a PlayerBackend, and every engine reaches it through that one contract — LegacyPlayer carries the not-yet-ported ones across unchanged, so the port swaps a seam rather than four implementations. Loading an item is now a single open carrying its start position, and the controller maps the engine's Phase back onto PlayerState using the queue, so nothing outside changes. LegacyPlayer drives the old PlayerBackend through the MediaPlayer contract, so engines not yet ported keep working during the migration and the two designs can be compared on one engine and one file. It reproduces the old load-then-play-then-seek sequence faithfully rather than a fixed-up version, because making it pass would defeat its purposePlayerUR-081Done
DR-246The seek strategy turns on an ability the engine declares, not on the container the stream arrives in. Capabilities::seeks_transcoded_in_place is stated by each engine — true for hls.js, which seeks within the VOD playlist it was handed; false for mpv, which cannot make the server transcode from a new offset — and the command asks the engine currently rendering instead of inferring from is_hls and use_html5. The item's transport is no longer read at the seek site at all. Re-negotiating a stream needs the repository, which sits above the engine, so the engine states the capability and the caller acts on it rather than the engine owning the whole decisionPlayerUR-040, UR-081Done
DR-247ExoPlayer can be told where to start. JellyTauPlayer.load(url, mediaId) had no way to express a start position, so every caller loaded and then seeked; the position is now handed to ExoPlayer with the media item via setMediaItem(item, startPositionMs), and the two-argument form delegates to it. Running the conformance cases on a device also settled which half of DR-241 was engine-specific: ExoPlayer already queues a seek issued before prepare() completes, so it never had the lost-seek defect mpv did — only the missing vocabulary for a start positionPlayerUR-081, UR-005Done
DR-250Stopping means nothing is playing, from any renderer — not "whatever we believe owns playback has been asked to stop". A background-audio handoff swaps which renderer that is, and the swap is bookkeeping that can be mid-flight: exit_background_audio marks the webview element the player again the moment it is called, while the element has not reloaded. The teardown's stop was gated on flags describing what the component started, so after a handoff it described a player that was no longer making sound and the stop was skipped — the audio stream kept running and the mini player adopted it, which is why a movie reappeared as an audio track. The stop is now unconditional (it is idempotent) and clears the handoff base and flag, so a later position read cannot be interpreted against a handoff that no longer existsPlayerUR-040, UR-005Done
DR-251A duration of zero is treated as "the engine does not know yet", and falls back to the runtime the item already carries. ExoPlayer reports C.TIME_UNSET until it resolves one and JellyTauPlayer.getDuration() maps that to 0.0, so the engine answered Some(0.0) rather than None — which satisfied every "unknown duration" fallback and left the seek bar with no scale. It presented as scrubbing being broken rather than as a duration that never arrived, and the catalog had the runtime the whole timePlayerUR-005, UR-040Done
DR-252Seconds reported by an engine are converted to a Duration only when finite and positive. Duration::from_secs_f64 panics on a negative or non-finite value and no engine promises otherwise: ExoPlayer reports C.TIME_UNSET (Long::MIN_VALUE, about -9.2e15) for a stream whose length it does not know, which is every background-audio handoff — /Audio/{id}/universal is a chunked, length-less transcode. Held as a float that junk was harmless; converted to a Duration by the MediaPlayer adapter it became a panic that killed the backend mid-handoff and left a black screen with no controls. One guard on the contract, used by every engine crossing into itPlayerUR-005Done
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
UR-079-DR-225, DR-226, DR-227, DR-228, DR-229, DR-230
UR-080IR-033DR-231, DR-232, DR-233, DR-234, DR-235, DR-236, DR-237
+
+
+

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
UT-212The stream-selection contract. Transport and PlaybackKind each serialise to exactly the tag the frontend matches ({"type":"hls"}, {"type":"directPlay"}, …) and round-trip; nested StreamSelection fields are camelCase on the wire including playbackKind, mediaSourceId and maxBitrate; only Transcode counts as transcoding, so a direct stream does not; a local file is a direct play over a local transport with no ladder. The ladder: every rung at or above a 1.12 Mbps source is marked redundant while the three that constrain it are not, Original is never marked for any bitrate including zero and unknown, an unreported source bitrate keeps all eight rungs offered, a 40 Mbps source marks none, and each option carries the ladder's own label and detailDR-224, DR-226Done
UT-213The direct-play negotiation, one test per branch, against PlaybackInfo fixtures whose shapes were all observed on a live server: a supported source direct-plays; a remuxable one direct-streams and reports itself as not transcoding; an unsupported codec transcodes; undecodable audio overrides the server's direct-play offer (silent picture is worse than a transcode); a pinned audio track forces a transcode; a ceiling below the source bitrate transcodes even though the codec is fine, and the ladder agrees that rung constrains it; direct play wins over direct stream when both are offered. Plus the ceiling: a per-playback override governs the stream being opened without disturbing the durable default the Settings screen shows, and dropping it returns to that defaultDR-225, DR-227Done
UT-214The loader comes from the transport, never the URL. hls.js is attached for hls when available and the element's own loader when not; progressive and local files load directly; the element's src is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a progressive stream whose URL contains .m3u8 is not given an HLS loader, and an hls stream whose URL contains no .m3u8 is. Both failed against the pre-DR-225 implementation before the fix landedDR-224Done
UT-215Waiting for the repository rather than racing it: it resolves immediately when the session is already restored, resolves when the session arrives later (the race the player page lost on mount), still rejects when there genuinely is no session, unsubscribes once settled so a later store change cannot re-settle it, and leaves no armed timer to reject an already-resolved promiseDR-013Done
UT-216The native-video opt-in is read from one place and only explicit truthy values enable it: absent, empty, 0, no, false and anything unrecognised all mean off, because a half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangleDR-231Done
UT-217A transcoded HLS stream on the native backend re-negotiates rather than seeking in place, while the same stream under hls.js still seeks in place — the cell that native video made reachable for the first timeDR-238Done
UT-218Every property name matched by the mpv event loop also appears in an observe_property call, asserted against the source because the registration cannot be observed at runtime without a live mpvDR-239Done
UT-219A fullscreen toggle moves the document only when an in-document <video> renders, and moves the OS window as well when a native surface doesDR-240Done
UT-220The conformance suite: opening at a position starts there and never at zero, a seek issued while opening is honoured and overrides the start it overtook, pause and play are observable, close is silent and idempotent, and an open cancelled by close never begins playingDR-242, DR-243In Progress
UT-221An engine that cannot report a duration does not erase the one the item carries: with the queue holding a 1800s item and the engine answering nothing usable, the controller still reports 1800sDR-251Done
UT-222The values that killed the backend are rejected rather than converted: C.TIME_UNSET as seconds, negatives, zero, NaN and both infinities all yield no duration, while a real runtime survivesDR-252Done
UT-223The adapter survives an engine that answers badly. A HostileBackend reports C.TIME_UNSET as seconds, NaN, both infinities, a negative and a zero; reading a snapshot yields no duration and a zero position rather than panicking, and a well-behaved engine still round-trips. The conformance suite could not have caught this — it only ever drives engines that report sane numbers, which is why it stayed green while a real one took the backend downDR-252Done
UT-224Stopping clears an active background-audio handoff, both the flag and the base offset, so a later position read cannot be interpreted against a handoff that no longer exists. Previously verified only by listening to a deviceDR-250Done
+
+

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
IT-018The conformance cases run against ExoPlayer on a device: opening from the beginning and at a position, a seek issued while still preparing, a seek after open, pause and play observable, stop silent and idempotent, and a load cancelled by stop never playing. The fixture is a silent WAV synthesised at setup, so the repo carries no media and the duration is exactDR-247Done
+
+
+

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/23/2026, 9:24:04 AM

+

Summary

+
    +
  • Total Files Scanned: 406
  • +
  • Total TRACES Found: 1136
  • +
  • Requirements Covered: +
      +
    • User Requirements (UR): 80
    • +
    • Integration Requirements (IR): 26
    • +
    • Development Requirements (DR): 212
    • +
    • 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-070, UR-071, UR-072, UR-073, UR-074, UR-075, UR-076, UR-077, UR-078, UR-079, UR-080, UR-081
+
+

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, IR-033
+
+

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-121, 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, DR-225, DR-226, DR-227, DR-228, DR-230, DR-231, DR-232, DR-233, DR-234, DR-235, DR-238, DR-239, DR-240, DR-241, DR-242, DR-243, DR-244, DR-245, DR-246, DR-250, DR-251, DR-252
+
+

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)

+ +

IR-033

+

Locations: 4 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: 9 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: 15 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-121

+

Locations: 5 file(s)

+ +

DR-123

+

Locations: 6 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: 2 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: 25 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: 3 file(s)

+ +

DR-225

+

Locations: 49 file(s)

+ +

DR-226

+

Locations: 24 file(s)

+ +

DR-227

+

Locations: 25 file(s)

+ +

DR-228

+

Locations: 26 file(s)

+ +

DR-230

+

Locations: 7 file(s)

+ +

DR-231

+

Locations: 13 file(s)

+ +

DR-232

+

Locations: 4 file(s)

+ +

DR-233

+

Locations: 7 file(s)

+ +

DR-234

+

Locations: 7 file(s)

+ +

DR-235

+

Locations: 6 file(s)

+ +

DR-238

+

Locations: 2 file(s)

+ +

DR-239

+

Locations: 2 file(s)

+ +

DR-240

+

Locations: 1 file(s)

+ +

DR-241

+

Locations: 3 file(s)

+ +

DR-242

+

Locations: 1 file(s)

+ +

DR-243

+

Locations: 4 file(s)

+ +

DR-244

+

Locations: 3 file(s)

+ +

DR-245

+

Locations: 5 file(s)

+ +

DR-246

+

Locations: 4 file(s)

+ +

DR-250

+

Locations: 3 file(s)

+ +

DR-251

+

Locations: 2 file(s)

+ +

DR-252

+

Locations: 3 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-002

+

Locations: 25 file(s)

+ +

UR-003

+

Locations: 50 file(s)

+ +

UR-004

+

Locations: 80 file(s)

+ +

UR-005

+

Locations: 119 file(s)

+ +

UR-007

+

Locations: 19 file(s)

+ +

UR-008

+

Locations: 4 file(s)

+ +

UR-011

+

Locations: 11 file(s)

+ +

UR-019

+

Locations: 18 file(s)

+ +

UR-020

+

Locations: 52 file(s)

+ +

UR-021

+

Locations: 14 file(s)

+ +

UR-023

+

Locations: 32 file(s)

+ +

UR-026

+

Locations: 19 file(s)

+ +

UR-027

+

Locations: 23 file(s)

+ +

UR-029

+

Locations: 5 file(s)

+ +

UR-030

+

Locations: 2 file(s)

+ +

UR-034

+

Locations: 19 file(s)

+ +

UR-035

+

Locations: 7 file(s)

+ +

UR-036

+

Locations: 5 file(s)

+ +

UR-037

+

Locations: 4 file(s)

+ +

UR-048

+

Locations: 6 file(s)

+ +

UR-049

+

Locations: 22 file(s)

+ +

UR-050

+

Locations: 9 file(s)

+ +

UR-051

+

Locations: 2 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-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-116

+

Locations: 4 file(s)

+ +

UT-117

+

Locations: 8 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-140

+

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-009

+

Locations: 14 file(s)

+ +

UR-018

+

Locations: 20 file(s)

+ +

UR-025

+

Locations: 68 file(s)

+ +

UR-028

+

Locations: 2 file(s)

+ +

UR-040

+

Locations: 87 file(s)

+ +

UR-041

+

Locations: 6 file(s)

+ +

UR-053

+

Locations: 17 file(s)

+ +

UR-054

+

Locations: 7 file(s)

+ +

UR-061

+

Locations: 4 file(s)

+ +

UR-066

+

Locations: 12 file(s)

+ +

UR-067

+

Locations: 42 file(s)

+ +

UR-070

+

Locations: 17 file(s)

+ +

UR-071

+

Locations: 66 file(s)

+ +

UR-073

+

Locations: 6 file(s)

+ +

UR-076

+

Locations: 44 file(s)

+ +

UR-079

+

Locations: 92 file(s)

+ +

IT-013

+

Locations: 4 file(s)

+ +

UT-061

+

Locations: 9 file(s)

+ +

UT-104

+

Locations: 7 file(s)

+ +

UT-107

+

Locations: 6 file(s)

+ +

UT-143

+

Locations: 3 file(s)

+ +

UT-144

+

Locations: 3 file(s)

+ +

UT-146

+

Locations: 3 file(s)

+ +

UT-147

+

Locations: 8 file(s)

+ +

UT-151

+

Locations: 7 file(s)

+ +

UT-170

+

Locations: 8 file(s)

+ +

UT-174

+

Locations: 2 file(s)

+ +

UT-175

+

Locations: 1 file(s)

+ +

UT-183

+

Locations: 2 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)

+ +

UT-207

+

Locations: 2 file(s)

+ +

UT-208

+

Locations: 1 file(s)

+ +

UT-212

+

Locations: 13 file(s)

+ +

UT-213

+

Locations: 14 file(s)

+ +

UR-052

+

Locations: 23 file(s)

+ +

UR-058

+

Locations: 11 file(s)

+ +

UR-063

+

Locations: 10 file(s)

+ +

UR-074

+

Locations: 66 file(s)

+ +

UR-075

+

Locations: 12 file(s)

+ +

UR-078

+

Locations: 15 file(s)

+ +

UT-145

+

Locations: 5 file(s)

+ +

UT-167

+

Locations: 1 file(s)

+ +

UT-168

+

Locations: 24 file(s)

+ +

UR-068

+

Locations: 14 file(s)

+ +

UT-214

+

Locations: 4 file(s)

+ +

UT-219

+

Locations: 1 file(s)

+ +

UR-010

+

Locations: 16 file(s)

+ +

UR-012

+

Locations: 21 file(s)

+ +

UR-017

+

Locations: 11 file(s)

+ +

UR-039

+

Locations: 1 file(s)

+ +

UR-059

+

Locations: 11 file(s)

+ +

UR-060

+

Locations: 7 file(s)

+ +

UR-072

+

Locations: 1 file(s)

+ +

UR-077

+

Locations: 4 file(s)

+ +

UT-186

+

Locations: 1 file(s)

+ +

UT-192

+

Locations: 1 file(s)

+ +

UT-201

+

Locations: 2 file(s)

+ +

UT-209

+

Locations: 1 file(s)

+ +

UT-215

+

Locations: 1 file(s)

+ +

UR-013

+

Locations: 2 file(s)

+ +

UR-014

+

Locations: 1 file(s)

+ +

UR-015

+

Locations: 13 file(s)

+ +

UR-022

+

Locations: 1 file(s)

+ +

UR-024

+

Locations: 3 file(s)

+ +

UR-031

+

Locations: 2 file(s)

+ +

UR-032

+

Locations: 7 file(s)

+ +

UR-033

+

Locations: 12 file(s)

+ +

UR-038

+

Locations: 1 file(s)

+ +

UR-042

+

Locations: 2 file(s)

+ +

UR-043

+

Locations: 2 file(s)

+ +

UR-044

+

Locations: 1 file(s)

+ +

UR-045

+

Locations: 1 file(s)

+ +

UR-046

+

Locations: 1 file(s)

+ +

UR-047

+

Locations: 1 file(s)

+ +

UR-057

+

Locations: 1 file(s)

+ +

UR-065

+

Locations: 25 file(s)

+ +

UR-069

+

Locations: 24 file(s)

+ +

UT-119

+

Locations: 2 file(s)

+ +

UR-080

+

Locations: 29 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-103

+

Locations: 6 file(s)

+ +

UT-115

+

Locations: 2 file(s)

+ +

UT-122

+

Locations: 11 file(s)

+ +

UT-125

+

Locations: 3 file(s)

+ +

UT-126

+

Locations: 2 file(s)

+ +

UT-154

+

Locations: 2 file(s)

+ +

UT-171

+

Locations: 2 file(s)

+ +

UT-172

+

Locations: 3 file(s)

+ +

UT-203

+

Locations: 10 file(s)

+ +

UT-204

+

Locations: 8 file(s)

+ +

UT-205

+

Locations: 10 file(s)

+ +

UT-206

+

Locations: 9 file(s)

+ +

UR-006

+

Locations: 8 file(s)

+ +

UR-081

+

Locations: 14 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-083

+

Locations: 1 file(s)

+ +

UT-084

+

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-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-120

+

Locations: 2 file(s)

+ +

UT-121

+

Locations: 2 file(s)

+ +

UT-127

+

Locations: 11 file(s)

+ +

UT-130

+

Locations: 3 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-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-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-210

+

Locations: 1 file(s)

+ +

UT-211

+

Locations: 2 file(s)

+ +

UT-216

+

Locations: 1 file(s)

+ +

UT-218

+

Locations: 1 file(s)

+ +

UT-220

+

Locations: 2 file(s)

+ +

UT-222

+

Locations: 1 file(s)

+ +

UR-016

+

Locations: 2 file(s)

+ +

UT-217

+

Locations: 1 file(s)

+ +

UT-221

+

Locations: 1 file(s)

+ +

UT-223

+

Locations: 1 file(s)

+ +

UT-224

+

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.

+

Two levels of ceiling

+

Location: src-tauri/src/repository/online.rs (TRACES: UR-074, UR-079 | DR-226)

+

There are two, and they are not the same thing:

+
+ + +
Set byLives untilRead via
Device defaultSettings (player_set_video_settings)Persisted; restored at startupstreaming_quality()
Per-playback overrideThe in-player picker (player_set_stream_quality)The next item starts playingplayback_quality_override()
+
+

effective_streaming_quality() resolves the pair — override first, else default — +and is the only thing stream construction may read. Every URL builder and the +PlaybackInfo negotiation go through it, for the reason the process-wide static +existed in the first place: if the negotiation and the URL builder disagree, the +cap leaks — the negotiation authorises a direct play the builder then never gets +to constrain, or the reverse.

+
+

The override exists because a single global cannot express "this 4K remux needs +a ceiling, that podcast does not". The picker had documented itself as a "this +film, this connection" control since it was written, but was implemented by +writing the default — so dropping one awkward film to 2 Mbps silently capped +every video played afterwards for the rest of the process, with Settings still +showing the old value. It is cleared on every player_play_item / +player_play_queue / player_play_tracks, which is what stops it surviving +into an autoplayed next episode where nobody would reopen the picker.

+
+

Stream selection

+

Location: src-tauri/src/repository/stream_selection.rs, +OnlineRepository::get_stream_selection (TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228)

+

Rust decides what stream. The player decides how to deliver it. That line +is the whole design. A backend with genuine adaptive selection (ExoPlayer over a +multi-variant playlist) is left to do it; Rust chooses what to request and never +paces bytes.

+

get_stream_selection returns one self-describing StreamSelection in place of +the bare URL get_video_stream_url used to hand out:

+
+ + + + + + +
FieldCarries
urlWhat to open
transportHls / Progressive / LocalFile — how to fetch it
playback_kindDirectPlay / DirectStream / Transcode — what the server is doing to the source
renditionThe negotiated ceiling and codecs; None for a direct play, which is the source
availableThe quality ladder as it applies to this media source (DR-227)
needs_transcodingDerived from playback_kind, so the rule is answered once
+
+

Both enums are serde-tagged ({"type":"hls"}) so the frontend matches a +discriminant rather than comparing text.

+
+

Why transport exists. VideoPlayer.svelte chose its loader with +url.includes(".m3u8"), in two places. Rust built that URL and knows exactly +what it is; re-deriving it downstream by substring match is a domain fact +reconstructed in the presentation layer — the same class of error as leaking +item-type taxonomy, and one that fails silently in both directions: a +progressive file served from a path containing the substring gets an HLS +loader, and a playlist served from a path without it does not.

+

The paths that never negotiate get the same shape from Rust rather than letting +a caller assemble one — media_local_selection for a downloaded file, +LiveStreamInfo.transport for a live channel — so there is no second place +where a transport is decided.

+
+

The playback-kind decision

+

decide_playback_kind is a free function and pure, so every branch is testable +from PlaybackInfo fixtures without a server. Order matters — the two +client-side overrides come first, because each describes a case where the +server's answer is right about the file and wrong about what this app will do +with it:

+
    +
  1. Undecodable audio → Transcode. Jellyfin 10.11.5 honours a +DirectPlayProfile's container and video codec but ignores its audio codec, +so it offers direct play for an E-AC-3 track the webview renders in silence. +A silent direct play is worse than a transcode.
  2. +
  3. A pinned audio track → Transcode. Not a defect in the server's answer, a +different question: the file has one default track and the viewer asked for +another.
  4. +
  5. Otherwise supports_direct_playDirectPlay, else supports_direct_stream +→ DirectStream, else Transcode.
  6. +
+

A direct stream is a remux — codecs copied, container repackaged. It is cheap +and is deliberately not counted as transcoding; conflating the two would report +a free passthrough as a server-side re-encode.

+
+

What this is worth, measured. Against the development server (Jellyfin +10.11.5), 400 items sampled for codec mix and 40 put through a real negotiation +per profile:

+
+ + +
ProfileDirect play
Linux / WebKitGTK (h264 only, 2ch)3/40 — 7%
Android / ExoPlayer (h264,hevc,vp8,vp9,av1,mpeg4 + ac3,eac3, 6ch)34/40 — 85%
+
+

The library is ~80% hevc (hevc+eac3 alone is a third of it), which is why the +two diverge so hard.

+

Read that 85% as a ceiling, not a result. It was measured with a profile +containing ac3,eac3. The Android device this was later run on reports neither +in its MediaCodecList — no Dolby licence, which is normal for a tablet — so +eac3 content, about a third of the sampled library, correctly transcodes there. +What any given device achieves depends on its own codec list, and on the +profile being derived from the renderer at all (DR-234), which it was not when +the figure was taken.

+

The payoff is still overwhelmingly Android, because that is where a real +decoder is already doing the work. Linux stays near 7% until libmpv decodes the +picture — the h264-only profile is a WebKitGTK constraint, not a JellyTau +choice, and is what linux-native-video-spike.md exists to remove. A reviewer +should not expect this code to fix Linux on its own.

+
+

The quality ladder per source

+

quality_options_for_source(source_bitrate) returns every rung, each marked with +exceeds_source: true when that rung's ceiling is at or above what the source +itself carries, so selecting it produces the same bytes as Original. The +frontend draws the list and drops the redundant rungs; it does not decide which +they are.

+
    +
  • Original is never marked — it is the source.
  • +
  • An unreported source bitrate (some containers have none; the sampled library +has avi files with no bitrate at all) marks nothing redundant, keeping +every rung offered. That is the safe direction: the viewer keeps every choice.
  • +
+

No adaptive ladder to preserve

+

TRACES: UR-079 | DR-229 (Won't Do)

+

Mid-playback re-negotiation on throughput was scoped and dropped on measurement. +A master playlist from this server carries exactly one EXT-X-STREAM-INF: +Jellyfin builds it from the single rendition the request asked for rather than +publishing a ladder. So there is no adaptation for hls.js to be preserving and +none that mpv would lose — the claim that there was is recorded in +playback-backend-unification.md and does not hold. "Adapt mid-stream" collapses +into "pick well at open", which is what the two levels of ceiling and the +per-source ladder already are.

+

Kept here because it is a measurement, not an opinion: a server that does +publish a ladder would change the answer, and the re-negotiation path below is +the hook that work would build on.

+

Re-negotiation

+

One mechanism, not two. player_seek_video, player_switch_audio_track and +player_set_stream_quality all return a tagged strategy saying who reloads — +the backend handles a native backend itself and hands the webview a +StreamSelection for reloadSource. Note the wire wart: tauri-specta keeps +these response fields snake_case (seek_offset), while the strategy tag itself +is camelCase.

+

The frontend names a variant and nothing else; the labels the picker shows are +served over IPC — from available on the selection, or +player_get_streaming_qualities for the Settings list.

+

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.

+

Stream Transport

+

Location: src/lib/player/streamTransport.ts +TRACES: UR-079 | DR-225 | UT-214

+

videoLoaderFor(selection, capabilities) picks the loader for the webview +<video> element — hlsjs, nativeHls, or direct — from the backend's tagged +selection.transport. elementSrcFor is its template companion: the element's +src is emptied only when hls.js is driving it.

+

The split is the point. The transport is the stream's property and comes from +Rust; whether a given loader exists is the browser's, and is the only thing +decided here.

+
+

This replaced currentStreamUrl.includes(".m3u8"), which appeared twice in +VideoPlayer.svelte — once in the HLS $effect and once inline in the +template's src. Rust builds that URL and knows what it is; re-deriving it +here by substring match was a domain fact reconstructed in the presentation +layer, and it fails silently in both directions. The two tests that pin it are +the ones that failed against the old implementation: a progressive stream +whose URL contains .m3u8 must not get an HLS loader, and an hls stream +whose URL contains no .m3u8 must.

+

Logic lives in a plain .ts module rather than in the component for the usual +reason — it is testable there. Same pattern as episodeStrip.ts.

+
+

VideoPlayer holds a currentSelection, not a URL string; currentStreamUrl is +derived from it. A reload replaces the selection wholesale (the adapter's +bridge takes a StreamSelection, not a URL), so transport and URL can never +drift apart. The background-audio handoff states the transport it is moving to — +progressive mp3 out, HLS back — via selectionAt(), rather than leaving it to be +inferred.

+

The quality picker is filled from selection.available (DR-227): rungs the +backend marked exceedsSource are not drawn, because they produce the same bytes +as Original. Nothing is optimistically assigned when the viewer picks a rung — +what the menu shows comes from the selection the backend hands back, since a +ceiling above the source bitrate is the source.

+

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
+
+

Video Stream Selection Flow

+

TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228

+

Before a video plays, Rust decides what stream — direct play, remux or +transcode, over which transport — and hands the player one self-describing +StreamSelection. The page no longer inspects the URL to work any of this out.

+
sequenceDiagram
+    participant Page as player/[id]/+page.svelte
+    participant Repo as HybridRepository
+    participant Online as OnlineRepository
+    participant Server as Jellyfin
+    participant VP as VideoPlayer.svelte
+
+    Page->>Repo: playerLocalMediaPath(id)
+    alt a completed download exists
+        Page->>Repo: mediaLocalSelection(path)
+        Note over Page: LocalFile / DirectPlay, no ladder —<br/>nothing about a file on disk re-negotiates
+    else stream from the server
+        Page->>Repo: getStreamSelection(id, mediaSourceId)
+        Repo->>Online: get_stream_selection()
+        Online->>Online: effective_streaming_quality()
+        Note over Online: per-playback override, else device default
+        Online->>Server: POST /Items/{id}/PlaybackInfo<br/>(device profile + ceiling)
+        Server-->>Online: MediaSource {supportsDirectPlay,<br/>supportsDirectStream, transcodingUrl, bitrate}
+        Online->>Online: decide_playback_kind()
+        alt Transcode
+            Online->>Online: adopt/stop prior play session,<br/>build HLS URL
+            Note over Online: Transport::Hls
+        else DirectPlay / DirectStream
+            Online->>Online: /Videos/{id}/stream?static=true
+            Note over Online: Transport::Progressive,<br/>rendition = None (it IS the source)
+        end
+        Online->>Online: quality_options_for_source(bitrate)
+        Online-->>Page: StreamSelection
+    end
+    Page->>VP: selection
+    VP->>VP: videoLoaderFor(selection, caps)
+    Note over VP: hls.js / native HLS / direct —<br/>from the tag, never from the URL
+
+

The selection travels with the stream from then on. A reload — a quality change, +an audio-track switch, a transcoded seek — returns a new selection through the +same tagged strategy response, so transport and URL can never disagree; and the +queue item carries the transport so player_seek_video picks its seek strategy +from the backend's decision rather than from the URL string.

+

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-232. 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-122/124/125 — the read-through capture. DR-121 shipped as backend-owned stream selection and left this spec
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
desktop-native-video.mdmpv draws video on every desktop platform, then the webview <video> path and hls.js are deleted. Converts a measured 7% direct-play rate toward Android's 85%. Stacked on backend-owned stream selection.
backend-owned-stream-selection.mdRust owns direct-play-vs-transcode, transport and quality; players consume one StreamSelection. Partly built — StreamSelection, Transport and the .m3u8 sniff removal have landed.
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. The adaptive-bitrate question it was waiting on is answered: the server publishes one EXT-X-STREAM-INF, so there is no ladder for mpv to lose (DR-229). StreamSelection (DR-225) is the contract to consume.
+
+

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 resolved — +the playlist carries one EXT-X-STREAM-INF, so finding 3 is false and there is +no adaptation for mpv to lose. The remaining blocker is the unexplained SIGSEGV +under G5, which is a lifetime problem, not a compositing one. +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 — resolved. Finding 3's premise is false. Finding 3 said mpv would +regress streaming quality because "the webview path already has real ABR via +hls.js". Three pieces of evidence in this repo suggested 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 now been run (2026-08-21, against the development +server, Jellyfin 10.11.5):

    +
    curl -s ".../Videos/<itemId>/master.m3u8?…&TranscodingProtocol=hls&…" \
    +  | grep -c EXT-X-STREAM-INF
    +1
    +
    +

    One line. The playlist carries a single EXT-X-STREAM-INF plus an +EXT-X-IMAGE-STREAM-INF trickplay entry, which is not a rendition. Jellyfin +builds the master playlist from the rendition the request asked for; it does +not publish a ladder. So there is no ABR to lose, and this blocker is +closed — hls.js is serving as an HLS demuxer, exactly as (2) above supposed, +and mpv gives up nothing by replacing it.

    +

    Recorded as DR-229 (Won't Do) rather than deleted, because it is a +measurement: a server that does publish a ladder would change the answer, and +the re-negotiation path is the hook that work would build on.

    +

    The direct-play path now exists. It did not when this spike was written — +every video play went through the HLS transcode endpoint. Backend-owned stream +selection (DR-225 … DR-230) built it: Rust negotiates direct play / direct +stream / transcode and hands every backend one StreamSelection carrying the +URL, the transport and the chosen rendition. That is the contract this +implementation consumes — mpv is a consumer of a decision already made, not a +place to re-derive it.

    +

    It also sizes the prize precisely. Measured over the same server, 40 items +through a real negotiation per profile:

    +
    + + +
    ProfileDirect play
    Linux / WebKitGTK — h264 only, 2ch7%
    Android / ExoPlayer — h264,hevc,vp8,vp9,av1,mpeg4 + ac3,eac3, 6ch85%
    +
    +

    The 85% is a ceiling, not a shipped result — it was measured with a +profile containing ac3,eac3, which the Android device later used for +verification does not support.

    +

    The library sampled is ~80% hevc. Linux sits at 7% solely because the +WebKitGTK profile can only claim h264 — not because of anything about the +server or the negotiation. mpv decodes hevc, so widening the Linux device +profile once mpv renders the picture is what converts that 7% toward the +Android figure. That conversion is the actual product of this work; the +compositing proven above is the mechanism that permits it.

    +
  • +
  • +

    🔴 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 read-through capture itself — DR-122, +DR-124, DR-125.

+

DR-121 has shipped and left this spec. The player quality selector, the +per-playback bitrate ceiling, and the backend-owned stream decision it needed +were built as backend-owned stream selection (DR-225 … DR-228) and are +described in +01-rust-backend.md and +03-data-flow.md. +The settings-level ceiling (DR-162) is the same section. What remains here is the +capture half only — this spec no longer specifies anything about choosing a +bitrate.

+

Requirements: UR-070, UR-071 → DR-122, DR-123, DR-124, DR-125; IR-032 +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 — moved out (shipped)

+

Bitrate selection in the player shipped as DR-225 … DR-228; see +01-rust-backend.md.

+

The one constraint here that the capture work still has to respect: a quality +change re-negotiates within HLS. Returning a progressive stream.mp4 for a +transcode means playback never starts, because the server encodes the whole file +before serving a byte (DR-140). That is why DR-122 below abandons a capture on a +quality change rather than trying to splice one.

+

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
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: Desktop native video — mpv renders the picture, everywhere

+

Status: Proposed +Requirements: UR-080 (new) → DR-231 … DR-237 (new); IR-033 (new) +UX spec: n/a — nothing about the player's appearance changes. What changes is +what is behind the controls. +Supersedes / revises: consumes and closes +linux-native-video-spike.md, whose gates +authorised exactly this spec and nothing more. Settles finding 2 of +playback-backend-unification.md on the +desktop; finding 3 was already settled by DR-229. Absorbs the video half of what +windows-native-audio-backend.md leaves open. +Depends on: backend-owned stream selection (DR-225 … DR-230), the branch +below this one. mpv is a consumer of StreamSelection, never a second place to +decide what to play.

+

Destination on completion: +05-platform-backends.md — a "Native +Video Compositing (Desktop)" section beside the existing Android one, which this +mirrors; and 01-rust-backend.md — the +device profile becomes renderer-dependent, beside the stream-selection section. +The spike is deleted in the same commit, its three traps and its +hardware-decode table folded in; they are the durable half.

+

Summary

+

mpv decodes and draws video on every desktop platform, composited beneath the +transparent webview, exactly as Android already does with ExoPlayer. The HTML5 +<video> path and hls.js are then deleted, not merely bypassed.

+

The user-visible change is that most video stops being re-encoded by the server +before it can be watched. The change for whoever maintains this is that video +goes from three renderers to two.

+

Motivation

+

The transcode is a decoder constraint, not a rendering one

+

Desktop video goes through an h264 HLS transcode because the picture is drawn by +a WebKitGTK <video> element, and that element decodes little else. The device +profile therefore claims h264 alone. That is not a statement about the machine +— the same machine runs mpv, which decodes essentially everything in the library +— it is a statement about which widget is holding the frame.

+

DR-228 made the cost measurable. Over 40 items negotiated against the development +server:

+
+ + +
ProfileDirect play
Desktop / WebKitGTK — h264 only, 2ch7%
Android / ExoPlayer — h264,hevc,vp8,vp9,av1,mpeg4 + ac3,eac3, 6ch85%
+
+

The sampled library is ~80% hevc. Those rows differ only by which component +decodes.

+

Moving the picture to mpv is what lets the desktop row claim what the machine +can actually do, and that — not the compositing — is the product.

+
+

The 85% is a ceiling, not a shipped result. It was measured with a profile +containing ac3,eac3. The Android device later used for verification reports +neither in its MediaCodecList — no Dolby licence, normal for a tablet — so +eac3 content, about a third of the sampled library, correctly transcodes there. +Realising any of this depends on DR-234, deriving the profile from the renderer +rather than from the platform, which is why that requirement is load-bearing +and not tidy-up.

+
+

One desktop video path, not two

+

This is why the spec covers Windows rather than stopping at Linux.

+

Today video has three renderers: ExoPlayer, the WebKitGTK <video> element, +and (on Android, via the opt-out) that same element again. A Linux-only version +of this work would make it four, permanently: mpv on Linux, HTML5 on Windows, +ExoPlayer on Android, plus hls.js underneath the HTML5 one. Every seek strategy, +every track switch, every quality change, every lifecycle bug would then have one +more place to be got right — and the HTML5 path would survive indefinitely +because something would still need it.

+

Finishing the job removes that: mpv on desktop, ExoPlayer on Android, and +hls.js, html5Adapter.ts, videoLoaderFor and the webview video element all +go. The maintenance win is the reason Windows is in this spec and not in a +follow-up that never gets written.

+

Three blockers are gone

+
    +
  1. Compositing works, including Wayland. The spike ran all six gates; the +2024 "not possible on Wayland at all" claim is out of date when the render API +is used instead of foreign-window embedding.
  2. +
  3. There is no ABR to lose. DR-229: the server's master playlist carries one +EXT-X-STREAM-INF. hls.js was demuxing, not adapting.
  4. +
  5. A direct-play path exists. It did not when the spike was written. DR-228 +built it; DR-230 proved the contract is player-agnostic.
  6. +
+

And on Windows specifically, tauri-plugin-libmpv lists Windows as its fully +tested platform — the inverse of the Linux situation the spike had to +disprove. The embedding difficulty was always WebKitGTK-specific.

+

Layer assignment

+
+ + + + + + + + + +
Logic / responsibilityLayerWhy it belongs there
Which codecs this device can decodeRustDomain: it is the input to Jellyfin's PlaybackInfo negotiation. It stops being a property of the platform and becomes a property of the renderer in use — see "The structural change".
Which backend renders videoRustRust already owns this (use_html5_element / VideoBackend). It stops being a cfg! constant and becomes a runtime fact.
What stream to play (direct / remux / transcode, transport, ceiling)Rust — already decidedDR-225. mpv consumes StreamSelection. Re-deriving any of it in a new backend would be the defect DR-225 exists to remove, restated.
Creating the GL surface, reparenting the webview, owning the render contextRust (platform layer)Native window and GL-context lifetime. Not presentation, and not expressible above the IPC boundary at all.
Render-context ↔ GL-context lifetime bindingRustA correctness invariant over native resources. DR-232.
Frame pacing (update callback, report_swap)RustTiming against the compositor; mpv's own contract.
Hardware-decode selectionRustA capability question about the machine, answered from what mpv reports it actually selected.
Z-order of controls over video, overlay chrome, letterbox colourFrontend / mpvPresentation. Controls already draw over a transparent webview on Android; mpv paints its own letterbox bars (better than the Android equivalent, which shipped DR-194 as a defect).
Whether the surface is visible right nowFrontendnativeVideoActive already exists and toggles data-native-video. Unchanged.
+
+

The structural change

+

Everything above is routine except one row, and it carries the whole benefit.

+

video_codecs in build_device_profile is a compile-time constant per +platform:

+
#![allow(unused)]
+fn main() {
+#[cfg(all(not(target_os = "android"), target_os = "linux"))]
+let (video_codecs, audio_codecs) = ("h264".to_string(), "aac,mp3,opus,…");
+}
+

That is correct only while a build has exactly one video renderer. It must be +derived from which renderer will decode this stream, which is runtime state.

+

It looks like configuration and is not: it is the input that decides whether the +server re-encodes, it changes when Jellyfin's API or our renderer changes, and +getting it wrong fails silently — a claimed codec the renderer cannot decode is +a black picture or silence, which is DR-148 and DR-228's audio override already.

+

Write this against "the active video renderer", never cfg!(target_os). It +is the single piece that must not be Linux-shaped, because phase 2 reuses it +unchanged.

+

Design

+

Backend and compositing (DR-231, IR-033)

+

An MpvVideoBackend beside the existing MpvBackend (audio). The mpv side — +render context, FBO, update callback, hwdec — is shared; only the surface +differs per platform:

+
+ + +
PlatformSurfaceStatus
Linux (X11 + Wayland)gdk_cairo_draw_from_gl() in the default vbox's draw handler, over a GdkGLContext on its GdkWindow. No reparenting — see belowRender path proven by the spike; the overlay approach it used is rejected
WindowsNative HWND child beneath a transparent WebView2Phase 2
+
+

vo=libmpv plus mpv_render_context_create with MPV_RENDER_PARAM_OPENGL_FBO. +Webview transparency via with_transparent(true) — no window-level transparency; +the spike showed it is neither used nor needed.

+

G1's untested half failed, and the design changed because of it.

+

Reparenting Tauri's webview into a GtkOverlay attaches cleanly and then aborts +the process on the first click. tauri-runtime-wry connects a +button-press handler to the webview that walks a hard-coded path:

+
#![allow(unused)]
+fn main() {
+webview.parent()            // "This one should be GtkBox"
+       .parent()            // ...and this one the GtkWindow
+       .downcast::<gtk::Window>().unwrap()
+}
+

An overlay makes that chain webview → GtkOverlay → GtkBox, the downcast fails, +and the panic is non-unwinding so it kills the app. Nothing in configuration +avoids it: on Linux attach_resize_handler is called unconditionally (the +Windows equivalent is guarded by is_decorated()), and the decoration check that +would make the handler inert runs after the unwrap.

+

So the webview is not moved at all. mpv draws into the default vbox's own +draw handler instead, via gdk_cairo_draw_from_gl() over a GdkGLContext +created on that widget's GdkWindow. GTK3 draws a container before its children, +so the webview composites on top for free — the same z-order the overlay was for, +without touching the widget tree Tauri walks.

+

That is strictly better than the overlay it replaces: no reparent, no extra +widget, and the arrangement cannot be broken by a Tauri upgrade that assumes its +own layout. It is also why "the surface attached successfully" is not the gate — +a click is.

+

Three traps from the spike, each of which cost a debugging cycle and each of +which looks like a platform limitation and is not:

+
    +
  1. LC_NUMERIC must be reset after gtk::init(). mpv refuses to start +under a non-C numeric locale. mpv_backend.rs already handles this but has no +GTK init in front of it; here 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 — there is epoxy_glFoo, a variable holding a lazily-resolving +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 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 misleads. See DR-233.
  6. +
+

Render-context lifetime (DR-232) — the crash defence

+

The spike's one unexplained SIGSEGV landed in a decoder thread with no Tauri, +GTK or GL frame in the stack, and three plausible causes failed to reproduce it +across ~13 minutes of targeted stress.

+

What is not unexplained is that the spike had no defence: it never calls +mpv_render_context_free and never tears down on unrealize, so nothing stopped +the GL context being recreated beneath the render context. That is DR-184 on +Android restated — a surface outliving its player.

+

Built as a requirement in its own right, not as a fix for a crash we cannot yet +reproduce:

+
    +
  • Render context created on realize, freed on unrealize, same thread, before +the GL context goes away.
  • +
  • The update callback is unregistered before the context is freed, so a +callback cannot land on a freed context.
  • +
  • Playback teardown and surface teardown are ordered, not racing.
  • +
+

If the crash recurs after this, it is a different bug and the likeliest cause is +out of the search space. If it does not, we needed this anyway.

+

Frame pacing (DR-233)

+

Register mpv_render_context_set_update_callback; redraw only when it reports a +frame ready; call mpv_render_context_report_swap after each render.

+

Recorded because the failure mode is a trap: driving queue_render() off the +frame clock every tick without reporting the swap leaves mpv nothing to time +against. It looks fine in a window and judders at fullscreen, which reads as +a compositing or GPU limit and is neither.

+

Renderer-dependent device profile (DR-234)

+

build_device_profile takes the active video renderer and derives the codec +lists from it:

+
+ + + +
RendererVideo codecsAudio (video direct play)Channels
mpv (desktop native)h264,hevc,vp8,vp9,av1,mpeg4platform list incl. ac3,eac3 where the sink can voice itfrom the audio route
WebKitGTK <video>h264webview-decodable set only2
ExoPlayer (Android)unchangedunchangedunchanged
+
+

The existing video_audio_codecs() narrowing exists because the webview decodes +a narrower audio set than the platform. With mpv decoding, that no longer +applies to the video path — but the multichannel bound still does, since a 5.1 +track direct-played into a 2-channel sink is silence or inaudible dialogue. Both +constraints stay, sourced from the renderer rather than assumed.

+

This is what converts the 7% figure upward (toward, not necessarily to, the 85% ceiling — see the caveat above), and it is also the change most able to break +playback silently — so it lands after compositing is proven, covered by the +DR-228 override tests.

+

Deleting the webview video path (DR-235)

+

get_player_status stops reporting use_html5_element: true on desktop; +supports_native_video becomes true there.

+

Deletion is staged, because a path cannot be removed while a shipped platform +still needs it:

+
+ + + +
PhaseLinuxWindowsHTML5 video path
1mpvHTML5alive — Windows needs it
2mpvmpvalive but unreached
3mpvmpvdeleted, with hls.js
+
+

Phase 3 is a real phase with its own acceptance criterion, not a "later". The +whole maintenance argument for including Windows collapses if the fork survives.

+

Android keeps ExoPlayer and keeps the webview as its documented opt-out; the +<audio> element and the background-audio handoff are untouched throughout.

+

What happens when mpv fails to initialise. With no HTML5 path there is no +silent fallback, and inventing one resurrects what we deleted. The +graceful-backend-init principle applies as written: fall back to the no-op +backend, emit backend-init-failed, and surface a real error rather than a black +rectangle. An honest failure beats a hidden downgrade to the transcode we are +trying to stop paying for.

+

Hardware decode (DR-236)

+

The spike established the load-bearing fact: hardware decode works through the +render API (hwdec-current reported nvdec-copy on the discrete GPU), so the +direct-play prize is not traded for software decoding.

+

Policy is decided from what mpv reports it selected, never from what it was +asked for:

+
    +
  • Prefer zero-copy VA-API on the integrated GPU where the driver is present.
  • +
  • auto reached for the discrete GPU in copy-back mode on a hybrid +Intel+NVIDIA laptop — the least efficient hardware path — so auto is a +fallback, not the default.
  • +
  • vaapi silently fell back to software on the spike box because vainfo was +absent. A missing driver must be detected and logged, not mistaken for a +compositing limit.
  • +
  • Log hwdec-current at start-up; knowing what was actually chosen is the whole +diagnostic value.
  • +
+

Windows: what phase 2 actually costs (DR-237)

+

Not hidden, because it is the part most likely to be underestimated:

+
    +
  • The surface is different code. WebView2 in an HWND, not GTK. A transparent +WebView2 over a native child window is a solved arrangement, but DR-231's +Linux surface does not transfer. Everything else does.
  • +
  • libmpv is currently a Linux-only dependency, and Windows is +cross-compiled from Linux via x86_64-pc-windows-msvc + cargo-xwin. Phase +2 must source a Windows libmpv (DLL + import library) into that cross-build and +ship the DLL in the NSIS bundle.
  • +
  • LGPL obligations follow the DLL. DR-216 already records them for Linux: +keep the linkage dynamic, ship libmpv's licence text with any bundle carrying +it. The Windows bundle inherits both.
  • +
  • bun run test:rust and CI must still build. Per the CI rule, any tool this +needs goes into the builder image and is pushed — never installed at job time.
  • +
+

Windows also gains a native audio decoder as a side effect, which is what +windows-native-audio-backend.md wants and +cannot currently have. If that spec lands first, phase 2 inherits its build work +and shrinks to the surface.

+

Out of scope

+
    +
  • Android. Unchanged in every respect.
  • +
  • macOS. Not a shipped target. If it becomes one it joins phase 2's shape.
  • +
  • Audio backends. mpv already plays audio on Linux; this adds a video +renderer beside it. Windows audio is its own spec.
  • +
  • HDR, tone mapping, multi-window. Not exercised by the spike at all.
  • +
  • Re-deciding what stream to play. DR-225 owns that. If this spec finds +itself choosing a URL, something has gone wrong.
  • +
+

Acceptance criteria

+

Phase 1 — Linux

+
    +
  • +Tauri's own webview reparents into the overlay (the untested half of G1), +on X11 and Wayland.
  • +
  • +Video plays, seeks and switches audio track in mpv, with the Svelte +controls composited over it and alpha blending intact.
  • +
  • +The render context is freed on unrealize and the update callback +unregistered before the free; a test demonstrates the ordering.
  • +
  • +A direct-play negotiation returns DirectPlay for an hevc source that +today returns Transcode, and it plays.
  • +
  • +Direct-play rate over the same 40-item sample rises from 7% toward the +Android figure. Record the number.
  • +
  • +mpv init failure emits backend-init-failed and surfaces an error rather +than falling back to a transcode.
  • +
  • +hwdec-current is logged and is not copy-back where zero-copy is available.
  • +
  • +A soak covering seek, track switch and fullscreen runs clean for an agreed +duration. The spike's SIGSEGV is why this is a criterion.
  • +
+

Phase 2 — Windows

+
    +
  • +libmpv links in the cargo-xwin cross-build; the DLL and its licence ship +in the NSIS bundle; any new tool lives in the builder image, not in a CI step.
  • +
  • +Video plays composited under a transparent WebView2.
  • +
  • +The device profile, lifetime and hwdec code are reused, not +reimplemented — a reviewer confirms no cfg!(target_os = "linux") guards +them.
  • +
+

Phase 3 — deletion

+
    +
  • +use_html5_element is false on every desktop platform.
  • +
  • +hls.js is gone from package.json; html5Adapter.ts, videoLoaderFor +and the <video> element are deleted; Android's opt-out and the +background-audio <audio> path still work.
  • +
+

Throughout

+
    +
  • +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 a reviewer confirms no stream decision +was reconstructed in the new backend.
  • +
  • +bindings.ts regenerated from Rust.
  • +
  • +bun run traces:validate passes; coverage stays ≥ the CI ratchet.
  • +
  • +The spike and this spec are folded into +05-platform-backends.md and both +deleted in the same commit.
  • +
+

Testing

+
    +
  • Rust, pure: the device profile per renderer — mpv claims hevc, the webview +does not, the multichannel bound survives both. The DR-234 table as a +table-driven test.
  • +
  • Rust, pure: PlaybackInfo fixtures that transcode under the webview +profile and direct-play under the mpv profile — the direct-play conversion as a unit +test, not only as a measurement.
  • +
  • Rust: teardown ordering — callback unregistered before context freed, freed +before GL context destroyed. Structure it so the ordering is assertable without +a live GL context.
  • +
  • Frontend: no desktop path selects an HTML5 video adapter. After phase 3, +the adapter does not exist and the test goes with it.
  • +
  • Manual / soak: the criterion above. The spike's automated fullscreen and +resize soaks are reusable and already written.
  • +
+

TRACES

+
+ + + + + + + +
PieceTag
mpv video backend + compositingUR-080 | DR-231, IR-033
Render-context lifetime bindingUR-080 | DR-232
Frame pacingUR-080 | DR-233
Renderer-dependent device profileUR-080, UR-070 | DR-234
Webview video path removedUR-080 | DR-235
Hardware-decode policyUR-080 | DR-236
Windows surface + cross-buildUR-080 | DR-237
+
+

Notes for the implementer

+
    +
  • Read the spike before writing a line. Its three traps and its +hardware-decode table are the most valuable things in this directory, and each +cost a debugging cycle to find.
  • +
  • mpv consumes StreamSelection; it does not decide. The transport is on the +queue item (DR-230). If you are parsing a URL, stop.
  • +
  • Guard nothing on cfg!(target_os = "linux") that phase 2 will need. That is +the one avoidable mistake here.
  • +
  • The Android backend is the reference for the shape of this — transparent +webview over a native surface at index 0. Read 05-platform-backends.md's +Android section for what shipped and what its defects were (DR-184 surface +lifetime, DR-194 letterbox).
  • +
  • Do not call sync/blocking APIs from mpv event callbacks that can re-enter the +player or hold a lock. The existing deadlock gotchas apply.
  • +
  • A parallel Claude session may be active in this repo — git diff before +"repairing" unexpected changes.
  • +
  • This branch is stacked on backend-owned stream selection. Rebase when that +merges rather than merging master into it.
  • +
+

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! 🚀

+

Native player — verification plan

+

What to check before the MediaPlayer contract and Linux native video reach +master.

+

This is not a generic smoke test. Every case below exists because something +specific went wrong, and most of them were found on hardware after the +automated suites were green. Treat the sequences as load-bearing: several +defects only appeared in a particular order of actions, and testing the same +features in a different order missed them entirely.

+

Companion to release-checklist.md, which covers the +release mechanics. This covers whether the player is fit to release at all.

+

What is risky about this change

+
    +
  • PlayerController now talks to a MediaPlayer contract instead of +PlayerBackend. Every engine reaches it through an adapter that did not exist +before (DR-245).
  • +
  • mpv decodes video on Linux for the first time, composited under the webview +(DR-231).
  • +
  • Seek strategy is driven by an ability each engine declares rather than by a +truth table (DR-246).
  • +
  • Two regressions were introduced during this work and caught only on a device: +a wrong capability for ExoPlayer (DR-246 follow-up) and a Duration panic +(DR-252). Both were invisible to the test suites.
  • +
+

The suites originally verified only engines that behave, which is why both +regressions passed them. That gap is now partly closed in code rather than in +this document: UT-223 drives a deliberately hostile engine — C.TIME_UNSET, +NaN, infinities, negatives — through the adapter, and fails with the exact +panic that produced a black screen on a tablet. UT-224 pins the handoff +clearing that was previously verified by listening to a device.

+

Prefer moving cases out of this file and into tests. Anything here that +could fail automatically should; a checklist depends on someone remembering to +follow it, and the two defects it was written for cost hardware time that would +have been better spent making the suites realistic. What is left below is what +genuinely needs eyes, ears, or a display — not what merely has not been +automated yet.

+

1. Automated gates

+

Cheap, fast, and non-negotiable. Run from the worktree.

+
bun run check                      # 0 errors, 0 warnings
+bun run test                       # frontend
+bun run test:rust                  # Rust
+bun run format:check
+bun run lint                       # 0 errors; warnings at or below the CI ratchet
+bun run check:boundary
+bun run traces:validate
+bun run traces:coverage            # at or above MIN_THRESHOLD
+cd src-tauri && cargo fmt --check && cargo clippy --all-targets -- -D warnings
+cargo clippy --all-targets --features conformance -- -D warnings
+
+

The eslint warning count is a ratchet: equal to the CI limit is a pass, one +over fails the build. Going one over is how a piece of dead state was found +during this work — do not raise the limit to get past it.

+

2. Engine conformance

+
bun run test:player                # mpv + legacy, desktop
+bun run test:player:android        # ExoPlayer, on a connected device
+
+

Expected, and each deviation is meaningful rather than noise:

+
+ + + +
EngineResultIf it differs
MpvPlayer9/9A real regression. Stop.
LegacyPlayer8/9The one failure is transport_settings_round_trip: the old trait has no mute or rate. Any other failure is a regression.
ExoPlayer (device)7/7Two cases are absent because the Kotlin player exposes no mute or rate.
+
+

A green conformance run is not sufficient evidence to ship. Both regressions +introduced during this work passed conformance.

+

3. Desktop (Linux)

+

Run with native video on, since that is what is new:

+
JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
+
+
    +
  • +Direct play — a file the server does not transcode. Picture and sound.
  • +
  • +Transcoded play — something the server must re-encode (4K, HEVC, or an +audio codec the renderer cannot take).
  • +
  • +Resume — an item watched previously on this install. The prompt +appears and playback starts at the offered position, not at zero. +(Resume is device-local — see "Known open".)
  • +
  • +Scrub on a direct-play item; position lands and playback continues.
  • +
  • +Scrub on a transcoded item. Separate case on purpose: it takes a +different path, and it silently did nothing for months (DR-238).
  • +
  • +Pause and resume — the button follows the player. It stopped doing so +when a property was handled but never observed (DR-239).
  • +
  • +Fullscreen — the window really fills the display. Measure it if +unsure: the log prints rendering WxH, and a height short of the panel +means the document went fullscreen and the window did not (DR-240).
  • +
  • +Exit the player — audio stops. Listen; do not assume.
  • +
  • +Audio-only playback still works: mini player, queue, next/previous.
  • +
  • +Nothing in the log matches PANIC or ERROR.
  • +
+

4. Android

+

The tablet needs the side-by-side build. Do not uninstall the release app +to make an install succeed — see "Known open" for why the normal command is +currently wrong.

+
bun run android:build --device
+./scripts/sync-android-sources.sh
+cd src-tauri/gen/android && ANDROID_HOME="$HOME/Android/Sdk" ./gradlew \
+  :app:assembleUniversalDebug -x :app:rustBuildUniversalDebug \
+  -x :app:rustBuildArm64Debug -x :app:rustBuildArmDebug \
+  -x :app:rustBuildX86Debug -x :app:rustBuildX86_64Debug
+adb install -r app/build/outputs/apk/universal/debug/app-universal-debug.apk
+
+

Confirm the package is com.dtourolle.jellytau.debug before installing:

+
aapt2 dump packagename <apk>
+
+

If it says com.dtourolle.jellytau, the suffix was lost — stop, re-sync and +re-assemble. Installing it would try to replace the real app.

+

Then, with adb logcat capturing:

+
    +
  • +

    +Play a video. Picture, sound, and controls.

    +
  • +
  • +

    +Scrub. The bar has a scale — a duration of 0.0 means the seek bar has +nothing to scrub against (DR-251).

    +
  • +
  • +

    +Transcoded seek lands rather than restarting the stream. ExoPlayer seeks a +transcode in place; declaring otherwise re-opened it (DR-246).

    +
  • +
  • +

    +PiP.

    +
  • +
  • +

    +Lockscreen: controls respond and position tracks.

    +
  • +
  • +

    +The handoff sequence, in this exact order: +1. play a video +2. enable background audio +3. background the app — audio continues +4. foreground the app — video returns +5. exit the player — everything stops

    +
    Steps 4 and 5 are where two separate defects lived (DR-250, DR-252). Doing
    +the same actions in another order finds neither.
    +
    +
  • +
  • +

    +grep -c 'PANIC at' <logcat> returns 0.

    +
  • +
+

5. Regression checks with a named cause

+

Each of these presented as something other than its cause, which is why they are +listed separately from the feature passes above.

+
+ + + + + + + +
Symptom to look forWas actuallyRef
Skip on a transcoded item does nothing, or jumps to zeroSeek strategy keyed on the container, not the engineDR-238, DR-246
Play/pause button does not follow the playerA property handled but never observed, so the event never arrivedDR-239
Fullscreen leaves a strip of desktopThe document went fullscreen, the window did notDR-240
Resume plays from the beginningA seek issued before the engine had a file was discardedDR-241
Scrub bar has no scaleDuration reported as 0.0 and believedDR-251
Black screen, no controls, after a background-audio round tripA junk duration converted to a Duration panicked the backendDR-252
Audio still playing after leaving the playerThe stop was aimed at whichever renderer bookkeeping believed was activeDR-250
+
+

Known open — decide, do not discover

+

None of these are fixed. Each needs an explicit ship / do-not-ship call rather +than being met with surprise during testing.

+
    +
  • Resume is device-local. Progress is read from the local database and +nothing consults the server's UserData. A fresh install, a second device or +a reinstall offers no resume even though the server knows the position. Not a +regression — it has always been so.
  • +
  • The background-audio handoff is an unconfirmed state swap. +exit_background_audio marks the video element the player again the moment it +is called, while the element has not reloaded. DR-250 makes the visible +symptom impossible; the race is intact and can still misdirect a lockscreen +command or a position read. See +media-player-controller.md.
  • +
  • 🔴 The side-by-side debug install is broken. bun run android:dev +produces an APK with the release application id, because the Tauri build +regenerates gen/build.gradle.kts after the sync drops the .debug suffix in. +It then fails on signatures, and its own error message advises uninstalling — +which would destroy the real app's data. Fix this before anyone else builds +for Android.
  • +
  • PlayerBackend still exists behind LegacyPlayer, and the frontend still +carries some playback state. DR-248 and DR-249 are not started.
  • +
+

Ship criteria

+

Ship when:

+
    +
  1. Every automated gate in §1 passes.
  2. +
  3. Conformance matches §2 exactly, deviations included.
  4. +
  5. §3 and §4 are complete, on real hardware, by a person.
  6. +
  7. §5 shows no symptom returning.
  8. +
  9. Every item in "Known open" has a recorded decision.
  10. +
+

Do not ship on green suites alone. Both regressions introduced during this work +passed every suite and were caught by a person using the app.

+

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..bfd8b11b --- /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..6aa5975f --- /dev/null +++ b/requirements.html @@ -0,0 +1,1193 @@ + + + + + + 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-079The app decides what stream to play and says so. Playing a video used to mean asking the server to re-encode it, always — a decision made nowhere, written down nowhere, and re-derived downstream by whoever needed it: the player worked out whether it had been handed a playlist by looking for .m3u8 in the URL. So a viewer paid for a transcode of a file their device could have played untouched, and the app could not tell them which it was. Now one negotiation produces one self-describing answer — direct play, remux, or transcode; over a playlist, a plain HTTP file, or a local one — and every renderer consumes that same answer instead of guessing from a string. On Android, where the player decodes almost everything the library holds, this stops around 85% of plays from starting a transcode nobody neededMediumDone
UR-080Video on the desktop plays as itself. The picture was drawn by a webview <video> element, which decodes little beyond h264 — so the app told the server it could accept only h264, and the server re-encoded almost everything before sending it. That was never a statement about the machine: the same machine already runs mpv for audio, which decodes essentially the whole library. Measured against a real library, 93% of desktop playback was a transcode nobody needed, against 15% on Android where a real decoder does the work. mpv now draws the picture, the app claims what it can genuinely decode, and video is sent as it was stored wherever that is possible — sparing the server the work, the network the bitrate, and the picture a generation of re-encodingMediumProposed
UR-081Playback behaves the same whichever engine renders itHighIn Progress
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
IR-033libmpv render-API integration for video: vo=libmpv driving an OpenGL FBO bound by the host toolkit, with GL entry points resolved through libepoxy. Note that libepoxy exports them as data symbols — there is no glFoo function, only an epoxy_glFoo variable holding a lazily-resolving pointer — so 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 on the first GL call. The epoxy crate resolves this correctly but is unusable, its gl_generator dependency pulling a yanked xml-rsPlaybackUR-080Proposed
+
+
+

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-225StreamSelection replaces the bare URL returned for playback: URL, Transport (hls / progressive / localFile), PlaybackKind (directPlay / directStream / transcode), the negotiated Rendition, the ladder this source can offer, and a needs_transcoding flag derived in Rust so "which kinds count as transcoding" is answered once. Both enums are serde-tagged ({"type":"hls"}) so the frontend matches a discriminant rather than comparing text. The field that mattered most is transport: VideoPlayer.svelte chose its loader with url.includes(".m3u8") in two places, a domain fact reconstructed in the presentation layer — the same class of error as leaking item-type taxonomy, and one that fails silently in both directions (a progressive file served from a path containing the substring gets an HLS loader; a playlist served from one without it does not). The paths that never negotiate — a downloaded file, a live channel — get the same shape from Rust (media_local_selection, LiveStreamInfo.transport) rather than having the page assemble one, so there is no second place where a transport is decidedPlaybackUR-079Done
DR-226The bandwidth ceiling is two-level: a durable device default (Settings, persisted, restored at startup) and a per-playback override the in-player picker sets. The picker's own documentation had called it a "this film, this connection" control since it was written, but it was implemented by writing the process-wide default — so dropping one awkward film to 2 Mbps silently capped every video played afterwards for the rest of the process, while the Settings screen still displayed the old value and nothing in the UI admitted the change. The override is cleared whenever playback moves to a new item, which is what keeps it from surviving into an autoplayed next episode where nobody would reopen the picker. effective_streaming_quality() is the single resolution point; every URL builder and the PlaybackInfo negotiation go through it, because a negotiation that authorises a direct play the URL builder then constrains (or the reverse) leaks the capPlaybackUR-074, UR-079Done
DR-227The quality picker is filled from what this media source can offer, not from the fixed eight-rung enum. Rust marks each rung exceeds_source when its ceiling is at or above the source's own bitrate — such a rung produces the same bytes as Original, so offering it is another way to spell one choice — and the frontend simply does not draw those. Original is never marked (it is the source) and a source whose bitrate the server does not report (the sampled library has avi files with none) marks nothing redundant, keeping every rung offered, which is the safe direction. The picker also shows what the server is actually doing with the stream, which only became knowable once PlaybackKind existed. Labels and detail lines come from Rust beside the numbers they describe, so a relabelled rung cannot drift out of step with what it doesUIUR-070, UR-079Done
DR-228Direct play and direct stream are negotiated rather than assumed away. get_video_stream_url always built an HLS transcode URL, so every video play burned server CPU even when the file would have played untouched. The decision now comes from PlaybackInfo under the device profile and the ceiling in force, with two client-side overrides applied on top because the server's answer is right about the file and wrong about what this app will do with it: undecodable audio (Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec but ignores its audio codec, so it offers direct play for an E-AC-3 track the webview renders in silence) and a viewer-pinned audio track the source file does not default to. Measured against the development server over a 400-item sample: 85% direct play on the Android profile, 7% on the Linux one — the library is ~80% hevc and WebKitGTK can only claim h264, so the Linux figure is a property of the renderer, not of this code, and is what linux-native-video-spike.md exists to change. A direct stream is a remux and is deliberately not counted as transcodingPlaybackUR-079Done
DR-229Mid-playback re-negotiation on throughput was scoped and dropped on measurement. The premise — that hls.js gives this app real adaptive bitrate and mpv would lose it — does not hold: a master playlist from the development server carries exactly one EXT-X-STREAM-INF, because Jellyfin builds it from the single rendition the request asked for rather than publishing a ladder. There is no adaptation to preserve, so "adapt mid-stream" collapses into "pick well at open", which is what DR-225 and DR-226 already are. Recorded rather than deleted because the conclusion is a measurement, not an opinion, and a server that does publish a ladder would change it — the DR-224 re-negotiation path is the hook that work would build onPlaybackUR-079Won't Do
DR-230Every player backend consumes the same selection, proving the contract is player-agnostic rather than HTML5-shaped. The queue item carries the negotiated transport, so player_seek_video picks its seek strategy from the backend's own decision instead of the last stream_url.contains(".m3u8") in the codebase; items queued by a path that never negotiated (audio tracks, direct URLs) carry None and fall back to needs_transcoding, which is exact rather than a guess because every transcode this app requests is HLS (DR-140). The webview adapter's bridge carries the whole selection rather than a URL, so the component's HLS effect reads a tag instead of searching a string, and the background-audio handoff states the transport it is moving to (progressive mp3 out, HLS back) rather than leaving it to be inferredPlaybackUR-003, UR-004, UR-079Done
DR-231An mpv video backend that composites beneath the transparent webview, the desktop counterpart of the Android TextureView arrangement. mpv renders through its render API into an FBO the toolkit binds (vo=libmpv + mpv_render_context_create with MPV_RENDER_PARAM_OPENGL_FBO), rather than by embedding a foreign window — which is what the 2024 "not possible on Wayland at all" conclusion was about and why it does not apply. On Linux that is a GtkOverlay with a GtkGLArea as main child and Tauri's own webview reparented as the overlay child; the mpv half is shared and only the surface differs per platform. Webview transparency alone suffices — no window-level transparency is used or neededPlaybackUR-080Proposed
DR-232The mpv render context's lifetime is bound to the GL context it draws into: created on realize, freed on unrealize, on the same thread, with the update callback unregistered before the free so a callback cannot land on a freed context. This is DR-184 on Android restated — a surface outliving its player — and it is a requirement in its own right rather than a fix for a specific crash. The spike observed one SIGSEGV in a decoder thread that three targeted soaks failed to reproduce; what is not in doubt is that the spike never called mpv_render_context_free and never tore down on unrealize, so nothing defended against the GL context being recreated underneath. Removing the likeliest cause is worth doing whether or not it was the causePlaybackUR-080Proposed
DR-233Frame pacing goes through mpv's update callback, with mpv_render_context_report_swap after each render. Recorded as a requirement because the failure mode misleads: driving the widget's frame clock every tick without reporting the swap leaves mpv with nothing to time against, which looks fine in a window and judders at fullscreen — reading as a compositing or GPU limit and being neitherPlaybackUR-080Proposed
DR-234The device profile is derived from the renderer that will decode the stream, not from a compile-time platform constant. video_codecs was #[cfg(target_os)], which is correct only while a build has one video renderer; once mpv and the webview element coexist it must be runtime state. This is the change that converts the measured 7% desktop direct-play rate toward the 85% the Android profile achieves on the same library, because the two differ by nothing except which component decodes. It looks like configuration and is not — it is the input that decides whether the server re-encodes, and getting it wrong fails silently, a claimed codec the renderer cannot decode being a black picture or silence (DR-148, and DR-227's audio override). The webview's narrower audio set stops applying to the video path once mpv decodes it, while the multichannel bound still does, since a 5.1 track direct-played into a two-channel sink is silence or inaudible dialogueRepositoryUR-080, UR-070In Progress
DR-235The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do hls.js, html5Adapter.ts, videoLoaderFor and the <video> element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio <audio> path is untouched. With no HTML5 fallback left, a failed mpv init emits backend-init-failed and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying forPlaybackUR-080Proposed
DR-236Hardware-decode policy is decided from what mpv reports it selected (hwdec-current), never from what it was asked for. The spike established that hardware decode works through the render API at all — the load-bearing result, since it means direct play is not bought with software decoding — but also that auto reached for the discrete GPU in copy-back mode on a hybrid Intel+NVIDIA laptop, the least efficient hardware path, and that vaapi fell back to software silently because the libva driver was absent. So zero-copy VA-API on the integrated GPU is preferred where the driver is present, auto is a fallback rather than the default, and a missing driver is detected and logged rather than mistaken for a compositing limitPlaybackUR-080Proposed
DR-237Windows reaches the same mpv path, reusing everything except the surface. The surface is genuinely different code — a native child window beneath a transparent WebView2, not GTK — but the render context, lifetime discipline, frame pacing, device profile and hwdec policy are shared, which is why none of them may be guarded on cfg!(target_os = "linux"). The cost is mostly build, not video: libmpv is currently a Linux-only dependency while Windows is cross-compiled from Linux via x86_64-pc-windows-msvc + cargo-xwin, so a Windows libmpv must reach that cross-build and its DLL must ship in the NSIS bundle, carrying the LGPL obligations DR-216 already records — dynamic linkage, licence text shipped alongside. Windows gains a native audio decoder as a side effect, which is what the long-blocked Windows audio work wants and cannot otherwise havePlaybackUR-080Proposed
DR-238A transcoded seek re-negotiates the stream on every renderer, not just the webview. Jellyfin produces a transcode from StartTimeTicks, so where a seek lands is a property of the request rather than of the stream in hand. determine_video_seek_strategy treated is_hls as a proxy for "seekable in place", which held only because hls.js was always the HLS renderer — it seeks within the VOD playlist it is handed and lets the server catch up. mpv's HLS demuxer cannot make the server transcode from a new offset, so with native video on, every transcoded seek became a backend seek that silently did nothing and presented as "resume does not work". The rule is now written on needs_transcoding with hls.js as the stated exception; all four webview cells are unchangedPlayerUR-040Done
DR-239Properties the mpv event loop handles are registered with observe_property. libmpv delivers PropertyChange only for observed properties, so a match arm for an unobserved one is unreachable code that reads as implemented — the handler is right there. pause was handled and never observed, so StateChanged was never emitted on pause or resume and the play/pause control never moved. It stayed invisible while Linux video played in the webview, because the <video> element's own DOM events drove that control; native video made the UI depend on the event that never camePlayerUR-005Done
DR-240Fullscreen moves whatever actually owns the pixels. requestFullscreen() fullscreens the document, which sufficed while every renderer lived inside it — the HTML5 <video> element is part of the document, so WebKit scaled it and the OS window's real size never mattered. A native surface is drawn behind the webview at window size, so a document-only fullscreen expands the page and leaves the picture where it was; on WebKitGTK the result is a maximised window with decorations still holding a strip of the screen, which reads as "fullscreen is broken" rather than as a windowing problem. Android needed the same rule for the system bars (DR-157); this is its desktop halfPlayerUR-066Done
DR-241A seek issued before MPV has a file to seek in is honoured, not dropped. loadfile returns as soon as the command is queued, so time-pos — a live property of the loaded file — does not resolve yet and setting it fails. The two callers that always hit that window are the ones a viewer notices: resume, and a transcoded seek, both of which re-open the stream and then ask for a position. The failed seek was discarded and the stream played from zero, which reads as "resume is broken" and "I cannot skip". The position is now held and applied by the FileLoaded handler; a seek that lands normally clears any deferred one, so the newer intent winsPlayerUR-040, UR-005Done
DR-242The player contract expresses intent, not device operations. MediaPlayer::open carries the start position, so no caller sequences load-then-seek and none can race an engine's asynchronous load; seek states a destination and leaves in-place-vs-re-open to the engine, which is the only layer that knows its own transport; snapshot is one coherent read; and Phase::Opening names the window a seek used to be lost in. Replaces PlayerBackend, which abstracted a device and required each of the three engines to re-derive the same rulesPlayerUR-081In Progress
DR-243Every engine passes one conformance suite, and a FakePlayer implements the contract deterministically. The suite is written before the second engine so it cannot encode whatever the first happened to do, and it drives readiness through a harness rather than sleeping. FakePlayer models the one behaviour that matters — opening is not instantaneous — so the load/seek race can be expressed on purpose, and lets the controller, queue, autoplay and session logic be tested with no engine at allPlayerUR-081In Progress
DR-244MpvPlayer implements MediaPlayer over libmpv, applying the start position at load time via mpv's own start option rather than seeking after an asynchronous loadfile, and holding a seek that arrives during Opening until the file loads. A standalone player-conformance binary runs the suite against it with audio and video routed to null, so a wrapper is verifiable without building or launching the appPlayerUR-081, UR-040Done
DR-245PlayerController holds a MediaPlayer rather than a PlayerBackend, and every engine reaches it through that one contract — LegacyPlayer carries the not-yet-ported ones across unchanged, so the port swaps a seam rather than four implementations. Loading an item is now a single open carrying its start position, and the controller maps the engine's Phase back onto PlayerState using the queue, so nothing outside changes. LegacyPlayer drives the old PlayerBackend through the MediaPlayer contract, so engines not yet ported keep working during the migration and the two designs can be compared on one engine and one file. It reproduces the old load-then-play-then-seek sequence faithfully rather than a fixed-up version, because making it pass would defeat its purposePlayerUR-081Done
DR-246The seek strategy turns on an ability the engine declares, not on the container the stream arrives in. Capabilities::seeks_transcoded_in_place is stated by each engine — true for hls.js, which seeks within the VOD playlist it was handed; false for mpv, which cannot make the server transcode from a new offset — and the command asks the engine currently rendering instead of inferring from is_hls and use_html5. The item's transport is no longer read at the seek site at all. Re-negotiating a stream needs the repository, which sits above the engine, so the engine states the capability and the caller acts on it rather than the engine owning the whole decisionPlayerUR-040, UR-081Done
DR-247ExoPlayer can be told where to start. JellyTauPlayer.load(url, mediaId) had no way to express a start position, so every caller loaded and then seeked; the position is now handed to ExoPlayer with the media item via setMediaItem(item, startPositionMs), and the two-argument form delegates to it. Running the conformance cases on a device also settled which half of DR-241 was engine-specific: ExoPlayer already queues a seek issued before prepare() completes, so it never had the lost-seek defect mpv did — only the missing vocabulary for a start positionPlayerUR-081, UR-005Done
DR-250Stopping means nothing is playing, from any renderer — not "whatever we believe owns playback has been asked to stop". A background-audio handoff swaps which renderer that is, and the swap is bookkeeping that can be mid-flight: exit_background_audio marks the webview element the player again the moment it is called, while the element has not reloaded. The teardown's stop was gated on flags describing what the component started, so after a handoff it described a player that was no longer making sound and the stop was skipped — the audio stream kept running and the mini player adopted it, which is why a movie reappeared as an audio track. The stop is now unconditional (it is idempotent) and clears the handoff base and flag, so a later position read cannot be interpreted against a handoff that no longer existsPlayerUR-040, UR-005Done
DR-251A duration of zero is treated as "the engine does not know yet", and falls back to the runtime the item already carries. ExoPlayer reports C.TIME_UNSET until it resolves one and JellyTauPlayer.getDuration() maps that to 0.0, so the engine answered Some(0.0) rather than None — which satisfied every "unknown duration" fallback and left the seek bar with no scale. It presented as scrubbing being broken rather than as a duration that never arrived, and the catalog had the runtime the whole timePlayerUR-005, UR-040Done
DR-252Seconds reported by an engine are converted to a Duration only when finite and positive. Duration::from_secs_f64 panics on a negative or non-finite value and no engine promises otherwise: ExoPlayer reports C.TIME_UNSET (Long::MIN_VALUE, about -9.2e15) for a stream whose length it does not know, which is every background-audio handoff — /Audio/{id}/universal is a chunked, length-less transcode. Held as a float that junk was harmless; converted to a Duration by the MediaPlayer adapter it became a panic that killed the backend mid-handoff and left a black screen with no controls. One guard on the contract, used by every engine crossing into itPlayerUR-005Done
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
UR-079-DR-225, DR-226, DR-227, DR-228, DR-229, DR-230
UR-080IR-033DR-231, DR-232, DR-233, DR-234, DR-235, DR-236, DR-237
+
+
+

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
UT-212The stream-selection contract. Transport and PlaybackKind each serialise to exactly the tag the frontend matches ({"type":"hls"}, {"type":"directPlay"}, …) and round-trip; nested StreamSelection fields are camelCase on the wire including playbackKind, mediaSourceId and maxBitrate; only Transcode counts as transcoding, so a direct stream does not; a local file is a direct play over a local transport with no ladder. The ladder: every rung at or above a 1.12 Mbps source is marked redundant while the three that constrain it are not, Original is never marked for any bitrate including zero and unknown, an unreported source bitrate keeps all eight rungs offered, a 40 Mbps source marks none, and each option carries the ladder's own label and detailDR-224, DR-226Done
UT-213The direct-play negotiation, one test per branch, against PlaybackInfo fixtures whose shapes were all observed on a live server: a supported source direct-plays; a remuxable one direct-streams and reports itself as not transcoding; an unsupported codec transcodes; undecodable audio overrides the server's direct-play offer (silent picture is worse than a transcode); a pinned audio track forces a transcode; a ceiling below the source bitrate transcodes even though the codec is fine, and the ladder agrees that rung constrains it; direct play wins over direct stream when both are offered. Plus the ceiling: a per-playback override governs the stream being opened without disturbing the durable default the Settings screen shows, and dropping it returns to that defaultDR-225, DR-227Done
UT-214The loader comes from the transport, never the URL. hls.js is attached for hls when available and the element's own loader when not; progressive and local files load directly; the element's src is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a progressive stream whose URL contains .m3u8 is not given an HLS loader, and an hls stream whose URL contains no .m3u8 is. Both failed against the pre-DR-225 implementation before the fix landedDR-224Done
UT-215Waiting for the repository rather than racing it: it resolves immediately when the session is already restored, resolves when the session arrives later (the race the player page lost on mount), still rejects when there genuinely is no session, unsubscribes once settled so a later store change cannot re-settle it, and leaves no armed timer to reject an already-resolved promiseDR-013Done
UT-216The native-video opt-in is read from one place and only explicit truthy values enable it: absent, empty, 0, no, false and anything unrecognised all mean off, because a half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangleDR-231Done
UT-217A transcoded HLS stream on the native backend re-negotiates rather than seeking in place, while the same stream under hls.js still seeks in place — the cell that native video made reachable for the first timeDR-238Done
UT-218Every property name matched by the mpv event loop also appears in an observe_property call, asserted against the source because the registration cannot be observed at runtime without a live mpvDR-239Done
UT-219A fullscreen toggle moves the document only when an in-document <video> renders, and moves the OS window as well when a native surface doesDR-240Done
UT-220The conformance suite: opening at a position starts there and never at zero, a seek issued while opening is honoured and overrides the start it overtook, pause and play are observable, close is silent and idempotent, and an open cancelled by close never begins playingDR-242, DR-243In Progress
UT-221An engine that cannot report a duration does not erase the one the item carries: with the queue holding a 1800s item and the engine answering nothing usable, the controller still reports 1800sDR-251Done
UT-222The values that killed the backend are rejected rather than converted: C.TIME_UNSET as seconds, negatives, zero, NaN and both infinities all yield no duration, while a real runtime survivesDR-252Done
UT-223The adapter survives an engine that answers badly. A HostileBackend reports C.TIME_UNSET as seconds, NaN, both infinities, a negative and a zero; reading a snapshot yields no duration and a zero position rather than panicking, and a well-behaved engine still round-trips. The conformance suite could not have caught this — it only ever drives engines that report sane numbers, which is why it stayed green while a real one took the backend downDR-252Done
UT-224Stopping clears an active background-audio handoff, both the flag and the base offset, so a later position read cannot be interpreted against a handoff that no longer exists. Previously verified only by listening to a deviceDR-250Done
+
+

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
IT-018The conformance cases run against ExoPlayer on a device: opening from the beginning and at a position, a seek issued while still preparing, a seek after open, pause and play observable, stop silent and idempotent, and a load cancelled by stop never playing. The fixture is a silent WAV synthesised at setup, so the repo carries no media and the duration is exactDR-247Done
+
+
+

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..1955113b --- /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#ir-033","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-121","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#dr-225","traceability.html#dr-226","traceability.html#dr-227","traceability.html#dr-228","traceability.html#dr-230","traceability.html#dr-231","traceability.html#dr-232","traceability.html#dr-233","traceability.html#dr-234","traceability.html#dr-235","traceability.html#dr-238","traceability.html#dr-239","traceability.html#dr-240","traceability.html#dr-241","traceability.html#dr-242","traceability.html#dr-243","traceability.html#dr-244","traceability.html#dr-245","traceability.html#dr-246","traceability.html#dr-250","traceability.html#dr-251","traceability.html#dr-252","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-002","traceability.html#ur-003","traceability.html#ur-004","traceability.html#ur-005","traceability.html#ur-007","traceability.html#ur-008","traceability.html#ur-011","traceability.html#ur-019","traceability.html#ur-020","traceability.html#ur-021","traceability.html#ur-023","traceability.html#ur-026","traceability.html#ur-027","traceability.html#ur-029","traceability.html#ur-030","traceability.html#ur-034","traceability.html#ur-035","traceability.html#ur-036","traceability.html#ur-037","traceability.html#ur-048","traceability.html#ur-049","traceability.html#ur-050","traceability.html#ur-051","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-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-116","traceability.html#ut-117","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-140","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-009","traceability.html#ur-018","traceability.html#ur-025","traceability.html#ur-028","traceability.html#ur-040","traceability.html#ur-041","traceability.html#ur-053","traceability.html#ur-054","traceability.html#ur-061","traceability.html#ur-066","traceability.html#ur-067","traceability.html#ur-070","traceability.html#ur-071","traceability.html#ur-073","traceability.html#ur-076","traceability.html#ur-079","traceability.html#it-013","traceability.html#ut-061","traceability.html#ut-104","traceability.html#ut-107","traceability.html#ut-143","traceability.html#ut-144","traceability.html#ut-146","traceability.html#ut-147","traceability.html#ut-151","traceability.html#ut-170","traceability.html#ut-174","traceability.html#ut-175","traceability.html#ut-183","traceability.html#ut-184","traceability.html#ut-185","traceability.html#ut-187","traceability.html#ut-188","traceability.html#ut-189","traceability.html#ut-207","traceability.html#ut-208","traceability.html#ut-212","traceability.html#ut-213","traceability.html#ur-052","traceability.html#ur-058","traceability.html#ur-063","traceability.html#ur-074","traceability.html#ur-075","traceability.html#ur-078","traceability.html#ut-145","traceability.html#ut-167","traceability.html#ut-168","traceability.html#ur-068","traceability.html#ut-214","traceability.html#ut-219","traceability.html#ur-010","traceability.html#ur-012","traceability.html#ur-017","traceability.html#ur-039","traceability.html#ur-059","traceability.html#ur-060","traceability.html#ur-072","traceability.html#ur-077","traceability.html#ut-186","traceability.html#ut-192","traceability.html#ut-201","traceability.html#ut-209","traceability.html#ut-215","traceability.html#ur-013","traceability.html#ur-014","traceability.html#ur-015","traceability.html#ur-022","traceability.html#ur-024","traceability.html#ur-031","traceability.html#ur-032","traceability.html#ur-033","traceability.html#ur-038","traceability.html#ur-042","traceability.html#ur-043","traceability.html#ur-044","traceability.html#ur-045","traceability.html#ur-046","traceability.html#ur-047","traceability.html#ur-057","traceability.html#ur-065","traceability.html#ur-069","traceability.html#ut-119","traceability.html#ur-080","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-103","traceability.html#ut-115","traceability.html#ut-122","traceability.html#ut-125","traceability.html#ut-126","traceability.html#ut-154","traceability.html#ut-171","traceability.html#ut-172","traceability.html#ut-203","traceability.html#ut-204","traceability.html#ut-205","traceability.html#ut-206","traceability.html#ur-006","traceability.html#ur-081","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-083","traceability.html#ut-084","traceability.html#ut-099","traceability.html#ut-100","traceability.html#ut-101","traceability.html#ut-102","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-120","traceability.html#ut-121","traceability.html#ut-127","traceability.html#ut-130","traceability.html#ut-141","traceability.html#ut-142","traceability.html#ut-148","traceability.html#ut-150","traceability.html#ut-152","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-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-210","traceability.html#ut-211","traceability.html#ut-216","traceability.html#ut-218","traceability.html#ut-220","traceability.html#ut-222","traceability.html#ur-016","traceability.html#ut-217","traceability.html#ut-221","traceability.html#ut-223","traceability.html#ut-224","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#stream-selection","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#stream-transport","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#video-stream-selection-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--moved-out-shipped","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/desktop-native-video.html#spec-desktop-native-video--mpv-renders-the-picture-everywhere","specs/desktop-native-video.html#summary","specs/desktop-native-video.html#motivation","specs/desktop-native-video.html#the-transcode-is-a-decoder-constraint-not-a-rendering-one","specs/desktop-native-video.html#one-desktop-video-path-not-two","specs/desktop-native-video.html#three-blockers-are-gone","specs/desktop-native-video.html#layer-assignment","specs/desktop-native-video.html#the-structural-change","specs/desktop-native-video.html#design","specs/desktop-native-video.html#backend-and-compositing-dr-231-ir-033","specs/desktop-native-video.html#render-context-lifetime-dr-232--the-crash-defence","specs/desktop-native-video.html#frame-pacing-dr-233","specs/desktop-native-video.html#renderer-dependent-device-profile-dr-234","specs/desktop-native-video.html#deleting-the-webview-video-path-dr-235","specs/desktop-native-video.html#hardware-decode-dr-236","specs/desktop-native-video.html#windows-what-phase-2-actually-costs-dr-237","specs/desktop-native-video.html#out-of-scope","specs/desktop-native-video.html#acceptance-criteria","specs/desktop-native-video.html#testing","specs/desktop-native-video.html#traces","specs/desktop-native-video.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","native-player-verification.html#native-player--verification-plan","native-player-verification.html#what-is-risky-about-this-change","native-player-verification.html#1-automated-gates","native-player-verification.html#2-engine-conformance","native-player-verification.html#3-desktop-linux","native-player-verification.html#4-android","native-player-verification.html#5-regression-checks-with-a-named-cause","native-player-verification.html#known-open--decide-do-not-discover","native-player-verification.html#ship-criteria","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":4834,"breadcrumbs":6,"title":4},"100":{"body":17,"breadcrumbs":4,"title":2},"1000":{"body":54,"breadcrumbs":11,"title":7},"1001":{"body":44,"breadcrumbs":5,"title":1},"1002":{"body":127,"breadcrumbs":5,"title":1},"1003":{"body":155,"breadcrumbs":6,"title":2},"1004":{"body":33,"breadcrumbs":5,"title":1},"1005":{"body":99,"breadcrumbs":9,"title":5},"1006":{"body":96,"breadcrumbs":9,"title":5},"1007":{"body":55,"breadcrumbs":10,"title":6},"1008":{"body":26,"breadcrumbs":7,"title":3},"1009":{"body":58,"breadcrumbs":6,"title":2},"101":{"body":16,"breadcrumbs":4,"title":2},"1010":{"body":132,"breadcrumbs":6,"title":2},"1011":{"body":108,"breadcrumbs":5,"title":1},"1012":{"body":69,"breadcrumbs":5,"title":1},"1013":{"body":75,"breadcrumbs":6,"title":2},"1014":{"body":201,"breadcrumbs":9,"title":6},"1015":{"body":75,"breadcrumbs":4,"title":1},"1016":{"body":89,"breadcrumbs":4,"title":1},"1017":{"body":179,"breadcrumbs":5,"title":2},"1018":{"body":0,"breadcrumbs":4,"title":1},"1019":{"body":138,"breadcrumbs":10,"title":7},"102":{"body":10,"breadcrumbs":4,"title":2},"1020":{"body":249,"breadcrumbs":6,"title":3},"1021":{"body":32,"breadcrumbs":10,"title":7},"1022":{"body":49,"breadcrumbs":4,"title":1},"1023":{"body":140,"breadcrumbs":5,"title":2},"1024":{"body":35,"breadcrumbs":5,"title":2},"1025":{"body":95,"breadcrumbs":5,"title":2},"1026":{"body":69,"breadcrumbs":4,"title":1},"1027":{"body":28,"breadcrumbs":4,"title":1},"1028":{"body":81,"breadcrumbs":5,"title":2},"1029":{"body":120,"breadcrumbs":11,"title":8},"103":{"body":10,"breadcrumbs":4,"title":2},"1030":{"body":40,"breadcrumbs":4,"title":1},"1031":{"body":0,"breadcrumbs":4,"title":1},"1032":{"body":120,"breadcrumbs":8,"title":5},"1033":{"body":80,"breadcrumbs":8,"title":5},"1034":{"body":72,"breadcrumbs":6,"title":3},"1035":{"body":167,"breadcrumbs":5,"title":2},"1036":{"body":82,"breadcrumbs":5,"title":2},"1037":{"body":0,"breadcrumbs":4,"title":1},"1038":{"body":275,"breadcrumbs":9,"title":6},"1039":{"body":93,"breadcrumbs":10,"title":7},"104":{"body":10,"breadcrumbs":4,"title":2},"1040":{"body":36,"breadcrumbs":7,"title":4},"1041":{"body":98,"breadcrumbs":9,"title":6},"1042":{"body":109,"breadcrumbs":9,"title":6},"1043":{"body":83,"breadcrumbs":7,"title":4},"1044":{"body":115,"breadcrumbs":10,"title":7},"1045":{"body":48,"breadcrumbs":5,"title":2},"1046":{"body":218,"breadcrumbs":5,"title":2},"1047":{"body":84,"breadcrumbs":4,"title":1},"1048":{"body":59,"breadcrumbs":4,"title":1},"1049":{"body":103,"breadcrumbs":5,"title":2},"105":{"body":17,"breadcrumbs":4,"title":2},"1050":{"body":65,"breadcrumbs":9,"title":7},"1051":{"body":46,"breadcrumbs":3,"title":1},"1052":{"body":67,"breadcrumbs":3,"title":1},"1053":{"body":58,"breadcrumbs":3,"title":1},"1054":{"body":114,"breadcrumbs":4,"title":2},"1055":{"body":0,"breadcrumbs":3,"title":1},"1056":{"body":91,"breadcrumbs":3,"title":1},"1057":{"body":85,"breadcrumbs":4,"title":2},"1058":{"body":38,"breadcrumbs":3,"title":1},"1059":{"body":38,"breadcrumbs":3,"title":1},"106":{"body":10,"breadcrumbs":4,"title":2},"1060":{"body":43,"breadcrumbs":6,"title":4},"1061":{"body":31,"breadcrumbs":4,"title":2},"1062":{"body":131,"breadcrumbs":4,"title":2},"1063":{"body":74,"breadcrumbs":3,"title":1},"1064":{"body":47,"breadcrumbs":3,"title":1},"1065":{"body":50,"breadcrumbs":4,"title":2},"1066":{"body":7,"breadcrumbs":5,"title":3},"1067":{"body":23,"breadcrumbs":3,"title":1},"1068":{"body":0,"breadcrumbs":4,"title":2},"1069":{"body":25,"breadcrumbs":4,"title":2},"107":{"body":15,"breadcrumbs":4,"title":2},"1070":{"body":21,"breadcrumbs":4,"title":2},"1071":{"body":0,"breadcrumbs":4,"title":2},"1072":{"body":28,"breadcrumbs":3,"title":1},"1073":{"body":30,"breadcrumbs":4,"title":2},"1074":{"body":27,"breadcrumbs":4,"title":2},"1075":{"body":0,"breadcrumbs":4,"title":2},"1076":{"body":18,"breadcrumbs":5,"title":3},"1077":{"body":18,"breadcrumbs":6,"title":4},"1078":{"body":27,"breadcrumbs":6,"title":4},"1079":{"body":20,"breadcrumbs":6,"title":4},"108":{"body":39,"breadcrumbs":4,"title":2},"1080":{"body":0,"breadcrumbs":3,"title":1},"1081":{"body":73,"breadcrumbs":4,"title":2},"1082":{"body":44,"breadcrumbs":4,"title":2},"1083":{"body":17,"breadcrumbs":4,"title":2},"1084":{"body":15,"breadcrumbs":4,"title":2},"1085":{"body":0,"breadcrumbs":3,"title":1},"1086":{"body":22,"breadcrumbs":7,"title":5},"1087":{"body":16,"breadcrumbs":5,"title":3},"1088":{"body":18,"breadcrumbs":5,"title":3},"1089":{"body":14,"breadcrumbs":4,"title":2},"109":{"body":119,"breadcrumbs":4,"title":2},"1090":{"body":29,"breadcrumbs":6,"title":4},"1091":{"body":0,"breadcrumbs":3,"title":1},"1092":{"body":24,"breadcrumbs":6,"title":4},"1093":{"body":28,"breadcrumbs":5,"title":3},"1094":{"body":21,"breadcrumbs":5,"title":3},"1095":{"body":0,"breadcrumbs":3,"title":1},"1096":{"body":14,"breadcrumbs":4,"title":2},"1097":{"body":9,"breadcrumbs":3,"title":1},"1098":{"body":0,"breadcrumbs":3,"title":1},"1099":{"body":19,"breadcrumbs":5,"title":3},"11":{"body":261,"breadcrumbs":4,"title":2},"110":{"body":33,"breadcrumbs":4,"title":2},"1100":{"body":10,"breadcrumbs":3,"title":1},"1101":{"body":0,"breadcrumbs":3,"title":1},"1102":{"body":9,"breadcrumbs":3,"title":1},"1103":{"body":14,"breadcrumbs":3,"title":1},"1104":{"body":0,"breadcrumbs":4,"title":2},"1105":{"body":16,"breadcrumbs":3,"title":1},"1106":{"body":26,"breadcrumbs":5,"title":3},"1107":{"body":14,"breadcrumbs":3,"title":1},"1108":{"body":80,"breadcrumbs":5,"title":3},"1109":{"body":18,"breadcrumbs":3,"title":1},"111":{"body":15,"breadcrumbs":4,"title":2},"1110":{"body":5,"breadcrumbs":4,"title":2},"1111":{"body":29,"breadcrumbs":8,"title":6},"1112":{"body":87,"breadcrumbs":6,"title":4},"1113":{"body":46,"breadcrumbs":6,"title":4},"1114":{"body":64,"breadcrumbs":5,"title":3},"1115":{"body":16,"breadcrumbs":6,"title":4},"1116":{"body":144,"breadcrumbs":5,"title":3},"1117":{"body":56,"breadcrumbs":5,"title":3},"1118":{"body":0,"breadcrumbs":4,"title":2},"1119":{"body":13,"breadcrumbs":4,"title":2},"112":{"body":33,"breadcrumbs":4,"title":2},"1120":{"body":9,"breadcrumbs":4,"title":2},"1121":{"body":9,"breadcrumbs":4,"title":2},"1122":{"body":16,"breadcrumbs":4,"title":2},"1123":{"body":21,"breadcrumbs":4,"title":2},"1124":{"body":0,"breadcrumbs":3,"title":1},"1125":{"body":16,"breadcrumbs":6,"title":4},"1126":{"body":25,"breadcrumbs":5,"title":3},"1127":{"body":13,"breadcrumbs":5,"title":3},"1128":{"body":12,"breadcrumbs":4,"title":2},"1129":{"body":31,"breadcrumbs":4,"title":2},"113":{"body":21,"breadcrumbs":4,"title":2},"1130":{"body":55,"breadcrumbs":5,"title":3},"1131":{"body":88,"breadcrumbs":4,"title":2},"1132":{"body":52,"breadcrumbs":7,"title":4},"1133":{"body":136,"breadcrumbs":5,"title":2},"1134":{"body":86,"breadcrumbs":6,"title":3},"1135":{"body":60,"breadcrumbs":6,"title":3},"1136":{"body":121,"breadcrumbs":6,"title":3},"1137":{"body":160,"breadcrumbs":5,"title":2},"1138":{"body":100,"breadcrumbs":8,"title":5},"1139":{"body":123,"breadcrumbs":7,"title":4},"114":{"body":9,"breadcrumbs":4,"title":2},"1140":{"body":41,"breadcrumbs":5,"title":2},"1141":{"body":16,"breadcrumbs":7,"title":5},"1142":{"body":143,"breadcrumbs":7,"title":5},"1143":{"body":53,"breadcrumbs":5,"title":3},"1144":{"body":72,"breadcrumbs":6,"title":4},"1145":{"body":83,"breadcrumbs":8,"title":6},"1146":{"body":30,"breadcrumbs":4,"title":2},"1147":{"body":63,"breadcrumbs":5,"title":3},"1148":{"body":149,"breadcrumbs":8,"title":6},"1149":{"body":37,"breadcrumbs":3,"title":1},"115":{"body":9,"breadcrumbs":4,"title":2},"1150":{"body":25,"breadcrumbs":7,"title":5},"1151":{"body":88,"breadcrumbs":6,"title":4},"1152":{"body":205,"breadcrumbs":5,"title":3},"1153":{"body":141,"breadcrumbs":5,"title":3},"1154":{"body":240,"breadcrumbs":6,"title":4},"1155":{"body":83,"breadcrumbs":7,"title":5},"1156":{"body":76,"breadcrumbs":7,"title":5},"1157":{"body":97,"breadcrumbs":4,"title":2},"1158":{"body":55,"breadcrumbs":3,"title":1},"1159":{"body":12,"breadcrumbs":5,"title":4},"116":{"body":9,"breadcrumbs":4,"title":2},"1160":{"body":32,"breadcrumbs":2,"title":1},"1161":{"body":44,"breadcrumbs":3,"title":2},"1162":{"body":0,"breadcrumbs":3,"title":2},"1163":{"body":17,"breadcrumbs":2,"title":1},"1164":{"body":23,"breadcrumbs":4,"title":3},"1165":{"body":78,"breadcrumbs":4,"title":3},"1166":{"body":18,"breadcrumbs":4,"title":3},"1167":{"body":46,"breadcrumbs":5,"title":4},"1168":{"body":15,"breadcrumbs":3,"title":2},"1169":{"body":37,"breadcrumbs":5,"title":4},"117":{"body":137,"breadcrumbs":4,"title":2},"1170":{"body":56,"breadcrumbs":5,"title":4},"1171":{"body":73,"breadcrumbs":3,"title":2},"1172":{"body":0,"breadcrumbs":4,"title":3},"1173":{"body":20,"breadcrumbs":3,"title":2},"1174":{"body":0,"breadcrumbs":2,"title":1},"1175":{"body":25,"breadcrumbs":5,"title":4},"1176":{"body":13,"breadcrumbs":5,"title":4},"1177":{"body":18,"breadcrumbs":4,"title":3},"1178":{"body":21,"breadcrumbs":3,"title":2},"1179":{"body":37,"breadcrumbs":3,"title":2},"118":{"body":9,"breadcrumbs":4,"title":2},"1180":{"body":33,"breadcrumbs":3,"title":2},"1181":{"body":27,"breadcrumbs":3,"title":2},"1182":{"body":13,"breadcrumbs":2,"title":1},"1183":{"body":14,"breadcrumbs":7,"title":5},"1184":{"body":14,"breadcrumbs":3,"title":1},"1185":{"body":0,"breadcrumbs":5,"title":3},"1186":{"body":26,"breadcrumbs":7,"title":5},"1187":{"body":11,"breadcrumbs":6,"title":4},"1188":{"body":5,"breadcrumbs":6,"title":4},"1189":{"body":4,"breadcrumbs":6,"title":4},"119":{"body":15,"breadcrumbs":4,"title":2},"1190":{"body":17,"breadcrumbs":5,"title":3},"1191":{"body":28,"breadcrumbs":4,"title":2},"1192":{"body":25,"breadcrumbs":4,"title":2},"1193":{"body":43,"breadcrumbs":5,"title":3},"1194":{"body":61,"breadcrumbs":4,"title":2},"1195":{"body":17,"breadcrumbs":4,"title":2},"1196":{"body":10,"breadcrumbs":3,"title":1},"1197":{"body":0,"breadcrumbs":3,"title":1},"1198":{"body":22,"breadcrumbs":5,"title":3},"1199":{"body":15,"breadcrumbs":6,"title":4},"12":{"body":0,"breadcrumbs":5,"title":3},"120":{"body":15,"breadcrumbs":4,"title":2},"1200":{"body":26,"breadcrumbs":5,"title":3},"1201":{"body":8,"breadcrumbs":3,"title":1},"1202":{"body":12,"breadcrumbs":7,"title":3},"121":{"body":49,"breadcrumbs":4,"title":2},"122":{"body":15,"breadcrumbs":4,"title":2},"123":{"body":17,"breadcrumbs":4,"title":2},"124":{"body":15,"breadcrumbs":4,"title":2},"125":{"body":145,"breadcrumbs":4,"title":2},"126":{"body":131,"breadcrumbs":4,"title":2},"127":{"body":15,"breadcrumbs":4,"title":2},"128":{"body":99,"breadcrumbs":4,"title":2},"129":{"body":9,"breadcrumbs":4,"title":2},"13":{"body":680,"breadcrumbs":9,"title":7},"130":{"body":21,"breadcrumbs":4,"title":2},"131":{"body":15,"breadcrumbs":4,"title":2},"132":{"body":27,"breadcrumbs":4,"title":2},"133":{"body":10,"breadcrumbs":4,"title":2},"134":{"body":17,"breadcrumbs":4,"title":2},"135":{"body":15,"breadcrumbs":4,"title":2},"136":{"body":71,"breadcrumbs":4,"title":2},"137":{"body":9,"breadcrumbs":4,"title":2},"138":{"body":9,"breadcrumbs":4,"title":2},"139":{"body":66,"breadcrumbs":4,"title":2},"14":{"body":191,"breadcrumbs":6,"title":4},"140":{"body":27,"breadcrumbs":4,"title":2},"141":{"body":15,"breadcrumbs":4,"title":2},"142":{"body":15,"breadcrumbs":4,"title":2},"143":{"body":56,"breadcrumbs":4,"title":2},"144":{"body":47,"breadcrumbs":4,"title":2},"145":{"body":39,"breadcrumbs":4,"title":2},"146":{"body":9,"breadcrumbs":4,"title":2},"147":{"body":63,"breadcrumbs":4,"title":2},"148":{"body":49,"breadcrumbs":4,"title":2},"149":{"body":15,"breadcrumbs":4,"title":2},"15":{"body":324,"breadcrumbs":9,"title":7},"150":{"body":38,"breadcrumbs":4,"title":2},"151":{"body":45,"breadcrumbs":4,"title":2},"152":{"body":59,"breadcrumbs":4,"title":2},"153":{"body":38,"breadcrumbs":4,"title":2},"154":{"body":39,"breadcrumbs":4,"title":2},"155":{"body":31,"breadcrumbs":4,"title":2},"156":{"body":17,"breadcrumbs":4,"title":2},"157":{"body":114,"breadcrumbs":4,"title":2},"158":{"body":84,"breadcrumbs":4,"title":2},"159":{"body":51,"breadcrumbs":4,"title":2},"16":{"body":164,"breadcrumbs":6,"title":4},"160":{"body":33,"breadcrumbs":4,"title":2},"161":{"body":93,"breadcrumbs":4,"title":2},"162":{"body":140,"breadcrumbs":4,"title":2},"163":{"body":36,"breadcrumbs":4,"title":2},"164":{"body":43,"breadcrumbs":4,"title":2},"165":{"body":17,"breadcrumbs":4,"title":2},"166":{"body":44,"breadcrumbs":4,"title":2},"167":{"body":24,"breadcrumbs":4,"title":2},"168":{"body":65,"breadcrumbs":4,"title":2},"169":{"body":49,"breadcrumbs":4,"title":2},"17":{"body":3,"breadcrumbs":5,"title":3},"170":{"body":115,"breadcrumbs":4,"title":2},"171":{"body":86,"breadcrumbs":4,"title":2},"172":{"body":15,"breadcrumbs":4,"title":2},"173":{"body":16,"breadcrumbs":4,"title":2},"174":{"body":38,"breadcrumbs":4,"title":2},"175":{"body":24,"breadcrumbs":4,"title":2},"176":{"body":131,"breadcrumbs":4,"title":2},"177":{"body":24,"breadcrumbs":4,"title":2},"178":{"body":21,"breadcrumbs":4,"title":2},"179":{"body":69,"breadcrumbs":4,"title":2},"18":{"body":27,"breadcrumbs":3,"title":1},"180":{"body":77,"breadcrumbs":4,"title":2},"181":{"body":33,"breadcrumbs":4,"title":2},"182":{"body":17,"breadcrumbs":4,"title":2},"183":{"body":24,"breadcrumbs":4,"title":2},"184":{"body":27,"breadcrumbs":4,"title":2},"185":{"body":9,"breadcrumbs":4,"title":2},"186":{"body":15,"breadcrumbs":4,"title":2},"187":{"body":10,"breadcrumbs":4,"title":2},"188":{"body":51,"breadcrumbs":4,"title":2},"189":{"body":38,"breadcrumbs":4,"title":2},"19":{"body":0,"breadcrumbs":4,"title":2},"190":{"body":15,"breadcrumbs":4,"title":2},"191":{"body":9,"breadcrumbs":4,"title":2},"192":{"body":42,"breadcrumbs":4,"title":2},"193":{"body":24,"breadcrumbs":4,"title":2},"194":{"body":9,"breadcrumbs":4,"title":2},"195":{"body":268,"breadcrumbs":4,"title":2},"196":{"body":17,"breadcrumbs":4,"title":2},"197":{"body":43,"breadcrumbs":4,"title":2},"198":{"body":22,"breadcrumbs":4,"title":2},"199":{"body":17,"breadcrumbs":4,"title":2},"2":{"body":2040,"breadcrumbs":5,"title":3},"20":{"body":160,"breadcrumbs":5,"title":3},"200":{"body":66,"breadcrumbs":4,"title":2},"201":{"body":120,"breadcrumbs":4,"title":2},"202":{"body":51,"breadcrumbs":4,"title":2},"203":{"body":70,"breadcrumbs":4,"title":2},"204":{"body":172,"breadcrumbs":4,"title":2},"205":{"body":63,"breadcrumbs":4,"title":2},"206":{"body":97,"breadcrumbs":4,"title":2},"207":{"body":114,"breadcrumbs":4,"title":2},"208":{"body":45,"breadcrumbs":4,"title":2},"209":{"body":74,"breadcrumbs":4,"title":2},"21":{"body":52,"breadcrumbs":5,"title":3},"210":{"body":27,"breadcrumbs":4,"title":2},"211":{"body":9,"breadcrumbs":4,"title":2},"212":{"body":9,"breadcrumbs":4,"title":2},"213":{"body":9,"breadcrumbs":4,"title":2},"214":{"body":15,"breadcrumbs":4,"title":2},"215":{"body":34,"breadcrumbs":4,"title":2},"216":{"body":17,"breadcrumbs":4,"title":2},"217":{"body":9,"breadcrumbs":4,"title":2},"218":{"body":21,"breadcrumbs":4,"title":2},"219":{"body":54,"breadcrumbs":4,"title":2},"22":{"body":424,"breadcrumbs":5,"title":3},"220":{"body":24,"breadcrumbs":4,"title":2},"221":{"body":59,"breadcrumbs":4,"title":2},"222":{"body":17,"breadcrumbs":4,"title":2},"223":{"body":31,"breadcrumbs":4,"title":2},"224":{"body":9,"breadcrumbs":4,"title":2},"225":{"body":11,"breadcrumbs":4,"title":2},"226":{"body":17,"breadcrumbs":4,"title":2},"227":{"body":313,"breadcrumbs":4,"title":2},"228":{"body":59,"breadcrumbs":4,"title":2},"229":{"body":73,"breadcrumbs":4,"title":2},"23":{"body":70,"breadcrumbs":6,"title":4},"230":{"body":66,"breadcrumbs":4,"title":2},"231":{"body":10,"breadcrumbs":4,"title":2},"232":{"body":9,"breadcrumbs":4,"title":2},"233":{"body":10,"breadcrumbs":4,"title":2},"234":{"body":34,"breadcrumbs":4,"title":2},"235":{"body":114,"breadcrumbs":4,"title":2},"236":{"body":17,"breadcrumbs":4,"title":2},"237":{"body":11,"breadcrumbs":4,"title":2},"238":{"body":19,"breadcrumbs":4,"title":2},"239":{"body":38,"breadcrumbs":4,"title":2},"24":{"body":0,"breadcrumbs":4,"title":2},"240":{"body":24,"breadcrumbs":4,"title":2},"241":{"body":350,"breadcrumbs":4,"title":2},"242":{"body":181,"breadcrumbs":4,"title":2},"243":{"body":167,"breadcrumbs":4,"title":2},"244":{"body":186,"breadcrumbs":4,"title":2},"245":{"body":48,"breadcrumbs":4,"title":2},"246":{"body":99,"breadcrumbs":4,"title":2},"247":{"body":31,"breadcrumbs":4,"title":2},"248":{"body":52,"breadcrumbs":4,"title":2},"249":{"body":61,"breadcrumbs":4,"title":2},"25":{"body":22,"breadcrumbs":4,"title":2},"250":{"body":49,"breadcrumbs":4,"title":2},"251":{"body":17,"breadcrumbs":4,"title":2},"252":{"body":17,"breadcrumbs":4,"title":2},"253":{"body":9,"breadcrumbs":4,"title":2},"254":{"body":24,"breadcrumbs":4,"title":2},"255":{"body":10,"breadcrumbs":4,"title":2},"256":{"body":32,"breadcrumbs":4,"title":2},"257":{"body":29,"breadcrumbs":4,"title":2},"258":{"body":44,"breadcrumbs":4,"title":2},"259":{"body":31,"breadcrumbs":4,"title":2},"26":{"body":15,"breadcrumbs":4,"title":2},"260":{"body":23,"breadcrumbs":4,"title":2},"261":{"body":17,"breadcrumbs":4,"title":2},"262":{"body":24,"breadcrumbs":4,"title":2},"263":{"body":10,"breadcrumbs":4,"title":2},"264":{"body":17,"breadcrumbs":4,"title":2},"265":{"body":10,"breadcrumbs":4,"title":2},"266":{"body":24,"breadcrumbs":4,"title":2},"267":{"body":17,"breadcrumbs":4,"title":2},"268":{"body":17,"breadcrumbs":4,"title":2},"269":{"body":10,"breadcrumbs":4,"title":2},"27":{"body":31,"breadcrumbs":4,"title":2},"270":{"body":54,"breadcrumbs":4,"title":2},"271":{"body":16,"breadcrumbs":4,"title":2},"272":{"body":17,"breadcrumbs":4,"title":2},"273":{"body":17,"breadcrumbs":4,"title":2},"274":{"body":17,"breadcrumbs":4,"title":2},"275":{"body":37,"breadcrumbs":4,"title":2},"276":{"body":30,"breadcrumbs":4,"title":2},"277":{"body":30,"breadcrumbs":4,"title":2},"278":{"body":17,"breadcrumbs":4,"title":2},"279":{"body":10,"breadcrumbs":4,"title":2},"28":{"body":10,"breadcrumbs":4,"title":2},"280":{"body":10,"breadcrumbs":4,"title":2},"281":{"body":10,"breadcrumbs":4,"title":2},"282":{"body":10,"breadcrumbs":4,"title":2},"283":{"body":42,"breadcrumbs":4,"title":2},"284":{"body":23,"breadcrumbs":4,"title":2},"285":{"body":23,"breadcrumbs":4,"title":2},"286":{"body":16,"breadcrumbs":4,"title":2},"287":{"body":29,"breadcrumbs":4,"title":2},"288":{"body":23,"breadcrumbs":4,"title":2},"289":{"body":10,"breadcrumbs":4,"title":2},"29":{"body":41,"breadcrumbs":4,"title":2},"290":{"body":22,"breadcrumbs":4,"title":2},"291":{"body":23,"breadcrumbs":4,"title":2},"292":{"body":23,"breadcrumbs":4,"title":2},"293":{"body":72,"breadcrumbs":4,"title":2},"294":{"body":51,"breadcrumbs":4,"title":2},"295":{"body":24,"breadcrumbs":4,"title":2},"296":{"body":17,"breadcrumbs":4,"title":2},"297":{"body":17,"breadcrumbs":4,"title":2},"298":{"body":176,"breadcrumbs":4,"title":2},"299":{"body":330,"breadcrumbs":4,"title":2},"3":{"body":0,"breadcrumbs":5,"title":3},"30":{"body":24,"breadcrumbs":4,"title":2},"300":{"body":556,"breadcrumbs":4,"title":2},"301":{"body":799,"breadcrumbs":4,"title":2},"302":{"body":125,"breadcrumbs":4,"title":2},"303":{"body":29,"breadcrumbs":4,"title":2},"304":{"body":77,"breadcrumbs":4,"title":2},"305":{"body":125,"breadcrumbs":4,"title":2},"306":{"body":345,"breadcrumbs":4,"title":2},"307":{"body":94,"breadcrumbs":4,"title":2},"308":{"body":214,"breadcrumbs":4,"title":2},"309":{"body":140,"breadcrumbs":4,"title":2},"31":{"body":10,"breadcrumbs":4,"title":2},"310":{"body":165,"breadcrumbs":4,"title":2},"311":{"body":33,"breadcrumbs":4,"title":2},"312":{"body":15,"breadcrumbs":4,"title":2},"313":{"body":122,"breadcrumbs":4,"title":2},"314":{"body":49,"breadcrumbs":4,"title":2},"315":{"body":37,"breadcrumbs":4,"title":2},"316":{"body":27,"breadcrumbs":4,"title":2},"317":{"body":39,"breadcrumbs":4,"title":2},"318":{"body":149,"breadcrumbs":4,"title":2},"319":{"body":60,"breadcrumbs":4,"title":2},"32":{"body":16,"breadcrumbs":4,"title":2},"320":{"body":15,"breadcrumbs":4,"title":2},"321":{"body":375,"breadcrumbs":4,"title":2},"322":{"body":114,"breadcrumbs":4,"title":2},"323":{"body":112,"breadcrumbs":4,"title":2},"324":{"body":49,"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":27,"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":16,"breadcrumbs":4,"title":2},"333":{"body":16,"breadcrumbs":4,"title":2},"334":{"body":16,"breadcrumbs":4,"title":2},"335":{"body":9,"breadcrumbs":4,"title":2},"336":{"body":9,"breadcrumbs":4,"title":2},"337":{"body":9,"breadcrumbs":4,"title":2},"338":{"body":15,"breadcrumbs":4,"title":2},"339":{"body":21,"breadcrumbs":4,"title":2},"34":{"body":69,"breadcrumbs":4,"title":2},"340":{"body":15,"breadcrumbs":4,"title":2},"341":{"body":9,"breadcrumbs":4,"title":2},"342":{"body":9,"breadcrumbs":4,"title":2},"343":{"body":9,"breadcrumbs":4,"title":2},"344":{"body":9,"breadcrumbs":4,"title":2},"345":{"body":9,"breadcrumbs":4,"title":2},"346":{"body":21,"breadcrumbs":4,"title":2},"347":{"body":21,"breadcrumbs":4,"title":2},"348":{"body":30,"breadcrumbs":4,"title":2},"349":{"body":58,"breadcrumbs":4,"title":2},"35":{"body":10,"breadcrumbs":4,"title":2},"350":{"body":15,"breadcrumbs":4,"title":2},"351":{"body":9,"breadcrumbs":4,"title":2},"352":{"body":9,"breadcrumbs":4,"title":2},"353":{"body":9,"breadcrumbs":4,"title":2},"354":{"body":9,"breadcrumbs":4,"title":2},"355":{"body":9,"breadcrumbs":4,"title":2},"356":{"body":9,"breadcrumbs":4,"title":2},"357":{"body":9,"breadcrumbs":4,"title":2},"358":{"body":9,"breadcrumbs":4,"title":2},"359":{"body":9,"breadcrumbs":4,"title":2},"36":{"body":118,"breadcrumbs":4,"title":2},"360":{"body":9,"breadcrumbs":4,"title":2},"361":{"body":9,"breadcrumbs":4,"title":2},"362":{"body":15,"breadcrumbs":4,"title":2},"363":{"body":9,"breadcrumbs":4,"title":2},"364":{"body":9,"breadcrumbs":4,"title":2},"365":{"body":9,"breadcrumbs":4,"title":2},"366":{"body":45,"breadcrumbs":4,"title":2},"367":{"body":37,"breadcrumbs":4,"title":2},"368":{"body":92,"breadcrumbs":4,"title":2},"369":{"body":139,"breadcrumbs":4,"title":2},"37":{"body":23,"breadcrumbs":4,"title":2},"370":{"body":475,"breadcrumbs":4,"title":2},"371":{"body":15,"breadcrumbs":4,"title":2},"372":{"body":604,"breadcrumbs":4,"title":2},"373":{"body":41,"breadcrumbs":4,"title":2},"374":{"body":137,"breadcrumbs":4,"title":2},"375":{"body":45,"breadcrumbs":4,"title":2},"376":{"body":27,"breadcrumbs":4,"title":2},"377":{"body":75,"breadcrumbs":4,"title":2},"378":{"body":286,"breadcrumbs":4,"title":2},"379":{"body":117,"breadcrumbs":4,"title":2},"38":{"body":36,"breadcrumbs":4,"title":2},"380":{"body":464,"breadcrumbs":4,"title":2},"381":{"body":42,"breadcrumbs":4,"title":2},"382":{"body":313,"breadcrumbs":4,"title":2},"383":{"body":659,"breadcrumbs":4,"title":2},"384":{"body":29,"breadcrumbs":3,"title":1},"385":{"body":62,"breadcrumbs":4,"title":2},"386":{"body":58,"breadcrumbs":4,"title":2},"387":{"body":44,"breadcrumbs":4,"title":2},"388":{"body":21,"breadcrumbs":4,"title":2},"389":{"body":21,"breadcrumbs":4,"title":2},"39":{"body":59,"breadcrumbs":4,"title":2},"390":{"body":23,"breadcrumbs":4,"title":2},"391":{"body":51,"breadcrumbs":4,"title":2},"392":{"body":51,"breadcrumbs":4,"title":2},"393":{"body":58,"breadcrumbs":4,"title":2},"394":{"body":15,"breadcrumbs":4,"title":2},"395":{"body":9,"breadcrumbs":4,"title":2},"396":{"body":15,"breadcrumbs":4,"title":2},"397":{"body":15,"breadcrumbs":4,"title":2},"398":{"body":15,"breadcrumbs":4,"title":2},"399":{"body":9,"breadcrumbs":4,"title":2},"4":{"body":855,"breadcrumbs":5,"title":3},"40":{"body":10,"breadcrumbs":4,"title":2},"400":{"body":34,"breadcrumbs":4,"title":2},"401":{"body":9,"breadcrumbs":4,"title":2},"402":{"body":15,"breadcrumbs":4,"title":2},"403":{"body":9,"breadcrumbs":4,"title":2},"404":{"body":93,"breadcrumbs":4,"title":2},"405":{"body":100,"breadcrumbs":4,"title":2},"406":{"body":157,"breadcrumbs":4,"title":2},"407":{"body":69,"breadcrumbs":4,"title":2},"408":{"body":63,"breadcrumbs":4,"title":2},"409":{"body":462,"breadcrumbs":4,"title":2},"41":{"body":31,"breadcrumbs":4,"title":2},"410":{"body":85,"breadcrumbs":4,"title":2},"411":{"body":108,"breadcrumbs":4,"title":2},"412":{"body":37,"breadcrumbs":4,"title":2},"413":{"body":9,"breadcrumbs":4,"title":2},"414":{"body":165,"breadcrumbs":4,"title":2},"415":{"body":87,"breadcrumbs":4,"title":2},"416":{"body":27,"breadcrumbs":4,"title":2},"417":{"body":9,"breadcrumbs":4,"title":2},"418":{"body":105,"breadcrumbs":4,"title":2},"419":{"body":149,"breadcrumbs":4,"title":2},"42":{"body":10,"breadcrumbs":4,"title":2},"420":{"body":73,"breadcrumbs":4,"title":2},"421":{"body":9,"breadcrumbs":4,"title":2},"422":{"body":73,"breadcrumbs":4,"title":2},"423":{"body":52,"breadcrumbs":4,"title":2},"424":{"body":9,"breadcrumbs":4,"title":2},"425":{"body":28,"breadcrumbs":4,"title":2},"426":{"body":9,"breadcrumbs":4,"title":2},"427":{"body":9,"breadcrumbs":4,"title":2},"428":{"body":15,"breadcrumbs":4,"title":2},"429":{"body":9,"breadcrumbs":4,"title":2},"43":{"body":64,"breadcrumbs":4,"title":2},"430":{"body":9,"breadcrumbs":4,"title":2},"431":{"body":15,"breadcrumbs":4,"title":2},"432":{"body":10,"breadcrumbs":4,"title":2},"433":{"body":107,"breadcrumbs":4,"title":2},"434":{"body":10,"breadcrumbs":4,"title":2},"435":{"body":23,"breadcrumbs":4,"title":2},"436":{"body":17,"breadcrumbs":4,"title":2},"437":{"body":52,"breadcrumbs":4,"title":2},"438":{"body":87,"breadcrumbs":4,"title":2},"439":{"body":9,"breadcrumbs":4,"title":2},"44":{"body":15,"breadcrumbs":4,"title":2},"440":{"body":17,"breadcrumbs":4,"title":2},"441":{"body":16,"breadcrumbs":4,"title":2},"442":{"body":10,"breadcrumbs":4,"title":2},"443":{"body":10,"breadcrumbs":4,"title":2},"444":{"body":10,"breadcrumbs":4,"title":2},"445":{"body":10,"breadcrumbs":4,"title":2},"446":{"body":9,"breadcrumbs":4,"title":2},"447":{"body":178,"breadcrumbs":4,"title":2},"448":{"body":176,"breadcrumbs":4,"title":2},"449":{"body":17,"breadcrumbs":4,"title":2},"45":{"body":9,"breadcrumbs":4,"title":2},"450":{"body":226,"breadcrumbs":4,"title":2},"451":{"body":10,"breadcrumbs":3,"title":1},"452":{"body":20,"breadcrumbs":4,"title":2},"453":{"body":20,"breadcrumbs":4,"title":2},"454":{"body":20,"breadcrumbs":4,"title":2},"455":{"body":20,"breadcrumbs":4,"title":2},"456":{"body":20,"breadcrumbs":4,"title":2},"457":{"body":20,"breadcrumbs":4,"title":2},"458":{"body":20,"breadcrumbs":4,"title":2},"459":{"body":20,"breadcrumbs":4,"title":2},"46":{"body":10,"breadcrumbs":4,"title":2},"460":{"body":20,"breadcrumbs":4,"title":2},"461":{"body":20,"breadcrumbs":4,"title":2},"462":{"body":20,"breadcrumbs":4,"title":2},"463":{"body":10,"breadcrumbs":4,"title":2},"464":{"body":10,"breadcrumbs":4,"title":2},"465":{"body":10,"breadcrumbs":4,"title":2},"466":{"body":45,"breadcrumbs":4,"title":2},"467":{"body":17,"breadcrumbs":4,"title":2},"468":{"body":80,"breadcrumbs":4,"title":2},"469":{"body":24,"breadcrumbs":4,"title":2},"47":{"body":10,"breadcrumbs":4,"title":2},"470":{"body":17,"breadcrumbs":4,"title":2},"471":{"body":17,"breadcrumbs":4,"title":2},"472":{"body":17,"breadcrumbs":4,"title":2},"473":{"body":24,"breadcrumbs":4,"title":2},"474":{"body":73,"breadcrumbs":4,"title":2},"475":{"body":59,"breadcrumbs":4,"title":2},"476":{"body":73,"breadcrumbs":4,"title":2},"477":{"body":66,"breadcrumbs":4,"title":2},"478":{"body":59,"breadcrumbs":4,"title":2},"479":{"body":108,"breadcrumbs":4,"title":2},"48":{"body":17,"breadcrumbs":4,"title":2},"480":{"body":10,"breadcrumbs":3,"title":1},"481":{"body":10,"breadcrumbs":3,"title":1},"482":{"body":10,"breadcrumbs":3,"title":1},"483":{"body":12,"breadcrumbs":4,"title":2},"484":{"body":12,"breadcrumbs":4,"title":2},"485":{"body":12,"breadcrumbs":4,"title":2},"486":{"body":13,"breadcrumbs":4,"title":2},"487":{"body":17,"breadcrumbs":4,"title":2},"488":{"body":10,"breadcrumbs":4,"title":2},"489":{"body":10,"breadcrumbs":4,"title":2},"49":{"body":15,"breadcrumbs":4,"title":2},"490":{"body":10,"breadcrumbs":4,"title":2},"491":{"body":10,"breadcrumbs":4,"title":2},"492":{"body":10,"breadcrumbs":4,"title":2},"493":{"body":10,"breadcrumbs":4,"title":2},"494":{"body":10,"breadcrumbs":4,"title":2},"495":{"body":10,"breadcrumbs":4,"title":2},"496":{"body":24,"breadcrumbs":4,"title":2},"497":{"body":10,"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":38,"breadcrumbs":4,"title":2},"500":{"body":10,"breadcrumbs":4,"title":2},"501":{"body":10,"breadcrumbs":4,"title":2},"502":{"body":10,"breadcrumbs":4,"title":2},"503":{"body":10,"breadcrumbs":4,"title":2},"504":{"body":10,"breadcrumbs":4,"title":2},"505":{"body":10,"breadcrumbs":4,"title":2},"506":{"body":10,"breadcrumbs":4,"title":2},"507":{"body":10,"breadcrumbs":4,"title":2},"508":{"body":10,"breadcrumbs":4,"title":2},"509":{"body":10,"breadcrumbs":4,"title":2},"51":{"body":110,"breadcrumbs":4,"title":2},"510":{"body":10,"breadcrumbs":4,"title":2},"511":{"body":24,"breadcrumbs":4,"title":2},"512":{"body":45,"breadcrumbs":4,"title":2},"513":{"body":31,"breadcrumbs":4,"title":2},"514":{"body":17,"breadcrumbs":4,"title":2},"515":{"body":17,"breadcrumbs":4,"title":2},"516":{"body":10,"breadcrumbs":4,"title":2},"517":{"body":10,"breadcrumbs":4,"title":2},"518":{"body":24,"breadcrumbs":4,"title":2},"519":{"body":17,"breadcrumbs":4,"title":2},"52":{"body":20,"breadcrumbs":4,"title":2},"520":{"body":17,"breadcrumbs":4,"title":2},"521":{"body":17,"breadcrumbs":4,"title":2},"522":{"body":17,"breadcrumbs":4,"title":2},"523":{"body":17,"breadcrumbs":4,"title":2},"524":{"body":80,"breadcrumbs":4,"title":2},"525":{"body":24,"breadcrumbs":4,"title":2},"526":{"body":21,"breadcrumbs":4,"title":2},"527":{"body":10,"breadcrumbs":4,"title":2},"528":{"body":24,"breadcrumbs":4,"title":2},"529":{"body":10,"breadcrumbs":4,"title":2},"53":{"body":18,"breadcrumbs":4,"title":2},"530":{"body":31,"breadcrumbs":4,"title":2},"531":{"body":24,"breadcrumbs":4,"title":2},"532":{"body":31,"breadcrumbs":4,"title":2},"533":{"body":24,"breadcrumbs":4,"title":2},"534":{"body":31,"breadcrumbs":4,"title":2},"535":{"body":10,"breadcrumbs":4,"title":2},"536":{"body":10,"breadcrumbs":4,"title":2},"537":{"body":10,"breadcrumbs":4,"title":2},"538":{"body":52,"breadcrumbs":4,"title":2},"539":{"body":38,"breadcrumbs":4,"title":2},"54":{"body":111,"breadcrumbs":4,"title":2},"540":{"body":24,"breadcrumbs":4,"title":2},"541":{"body":48,"breadcrumbs":4,"title":2},"542":{"body":24,"breadcrumbs":4,"title":2},"543":{"body":31,"breadcrumbs":4,"title":2},"544":{"body":24,"breadcrumbs":4,"title":2},"545":{"body":17,"breadcrumbs":4,"title":2},"546":{"body":17,"breadcrumbs":4,"title":2},"547":{"body":11,"breadcrumbs":4,"title":2},"548":{"body":10,"breadcrumbs":4,"title":2},"549":{"body":10,"breadcrumbs":4,"title":2},"55":{"body":98,"breadcrumbs":4,"title":2},"550":{"body":15,"breadcrumbs":4,"title":2},"551":{"body":12,"breadcrumbs":4,"title":2},"552":{"body":10,"breadcrumbs":4,"title":2},"553":{"body":17,"breadcrumbs":4,"title":2},"554":{"body":19,"breadcrumbs":4,"title":2},"555":{"body":10,"breadcrumbs":4,"title":2},"556":{"body":17,"breadcrumbs":4,"title":2},"557":{"body":10,"breadcrumbs":4,"title":2},"558":{"body":10,"breadcrumbs":4,"title":2},"559":{"body":17,"breadcrumbs":4,"title":2},"56":{"body":15,"breadcrumbs":4,"title":2},"560":{"body":10,"breadcrumbs":4,"title":2},"561":{"body":17,"breadcrumbs":4,"title":2},"562":{"body":10,"breadcrumbs":4,"title":2},"563":{"body":10,"breadcrumbs":4,"title":2},"564":{"body":10,"breadcrumbs":4,"title":2},"565":{"body":10,"breadcrumbs":4,"title":2},"566":{"body":8,"breadcrumbs":6,"title":4},"567":{"body":27,"breadcrumbs":3,"title":1},"568":{"body":65,"breadcrumbs":5,"title":3},"569":{"body":0,"breadcrumbs":4,"title":2},"57":{"body":57,"breadcrumbs":4,"title":2},"570":{"body":20,"breadcrumbs":5,"title":3},"571":{"body":185,"breadcrumbs":5,"title":3},"572":{"body":69,"breadcrumbs":6,"title":4},"573":{"body":18,"breadcrumbs":6,"title":4},"574":{"body":9,"breadcrumbs":6,"title":4},"575":{"body":11,"breadcrumbs":4,"title":2},"576":{"body":10,"breadcrumbs":4,"title":2},"577":{"body":13,"breadcrumbs":4,"title":2},"578":{"body":61,"breadcrumbs":4,"title":2},"579":{"body":0,"breadcrumbs":4,"title":2},"58":{"body":77,"breadcrumbs":4,"title":2},"580":{"body":13,"breadcrumbs":5,"title":3},"581":{"body":20,"breadcrumbs":4,"title":2},"582":{"body":34,"breadcrumbs":4,"title":2},"583":{"body":0,"breadcrumbs":4,"title":2},"584":{"body":21,"breadcrumbs":5,"title":3},"585":{"body":16,"breadcrumbs":3,"title":1},"586":{"body":0,"breadcrumbs":4,"title":2},"587":{"body":37,"breadcrumbs":4,"title":2},"588":{"body":36,"breadcrumbs":3,"title":1},"589":{"body":0,"breadcrumbs":4,"title":2},"59":{"body":9,"breadcrumbs":4,"title":2},"590":{"body":14,"breadcrumbs":6,"title":4},"591":{"body":17,"breadcrumbs":6,"title":4},"592":{"body":15,"breadcrumbs":6,"title":4},"593":{"body":14,"breadcrumbs":6,"title":4},"594":{"body":27,"breadcrumbs":5,"title":3},"595":{"body":0,"breadcrumbs":3,"title":1},"596":{"body":23,"breadcrumbs":6,"title":4},"597":{"body":26,"breadcrumbs":6,"title":4},"598":{"body":21,"breadcrumbs":5,"title":3},"599":{"body":0,"breadcrumbs":4,"title":2},"6":{"body":18540,"breadcrumbs":5,"title":3},"60":{"body":60,"breadcrumbs":4,"title":2},"600":{"body":19,"breadcrumbs":4,"title":2},"601":{"body":15,"breadcrumbs":3,"title":1},"602":{"body":22,"breadcrumbs":4,"title":2},"603":{"body":10,"breadcrumbs":3,"title":1},"604":{"body":23,"breadcrumbs":3,"title":1},"605":{"body":0,"breadcrumbs":7,"title":4},"606":{"body":17,"breadcrumbs":4,"title":1},"607":{"body":0,"breadcrumbs":5,"title":2},"608":{"body":24,"breadcrumbs":4,"title":1},"609":{"body":11,"breadcrumbs":4,"title":1},"61":{"body":55,"breadcrumbs":4,"title":2},"610":{"body":18,"breadcrumbs":4,"title":1},"611":{"body":49,"breadcrumbs":5,"title":2},"612":{"body":16,"breadcrumbs":5,"title":2},"613":{"body":0,"breadcrumbs":5,"title":2},"614":{"body":16,"breadcrumbs":7,"title":4},"615":{"body":14,"breadcrumbs":7,"title":4},"616":{"body":12,"breadcrumbs":7,"title":4},"617":{"body":0,"breadcrumbs":5,"title":2},"618":{"body":5,"breadcrumbs":5,"title":2},"619":{"body":9,"breadcrumbs":7,"title":4},"62":{"body":62,"breadcrumbs":4,"title":2},"620":{"body":9,"breadcrumbs":5,"title":2},"621":{"body":8,"breadcrumbs":5,"title":2},"622":{"body":14,"breadcrumbs":4,"title":1},"623":{"body":0,"breadcrumbs":4,"title":1},"624":{"body":23,"breadcrumbs":5,"title":2},"625":{"body":19,"breadcrumbs":5,"title":2},"626":{"body":26,"breadcrumbs":5,"title":2},"627":{"body":0,"breadcrumbs":5,"title":2},"628":{"body":20,"breadcrumbs":6,"title":3},"629":{"body":13,"breadcrumbs":5,"title":2},"63":{"body":28,"breadcrumbs":4,"title":2},"630":{"body":13,"breadcrumbs":6,"title":3},"631":{"body":56,"breadcrumbs":4,"title":1},"632":{"body":29,"breadcrumbs":4,"title":1},"633":{"body":18,"breadcrumbs":4,"title":3},"634":{"body":18,"breadcrumbs":3,"title":2},"635":{"body":340,"breadcrumbs":3,"title":2},"636":{"body":243,"breadcrumbs":3,"title":2},"637":{"body":362,"breadcrumbs":4,"title":3},"638":{"body":138,"breadcrumbs":4,"title":3},"639":{"body":3,"breadcrumbs":6,"title":3},"64":{"body":34,"breadcrumbs":4,"title":2},"640":{"body":502,"breadcrumbs":7,"title":4},"641":{"body":122,"breadcrumbs":9,"title":6},"642":{"body":224,"breadcrumbs":7,"title":4},"643":{"body":71,"breadcrumbs":6,"title":3},"644":{"body":74,"breadcrumbs":5,"title":2},"645":{"body":50,"breadcrumbs":5,"title":2},"646":{"body":80,"breadcrumbs":4,"title":1},"647":{"body":156,"breadcrumbs":4,"title":1},"648":{"body":82,"breadcrumbs":6,"title":3},"649":{"body":77,"breadcrumbs":5,"title":2},"65":{"body":22,"breadcrumbs":4,"title":2},"650":{"body":231,"breadcrumbs":5,"title":2},"651":{"body":62,"breadcrumbs":6,"title":3},"652":{"body":14,"breadcrumbs":7,"title":4},"653":{"body":119,"breadcrumbs":7,"title":4},"654":{"body":92,"breadcrumbs":5,"title":2},"655":{"body":217,"breadcrumbs":6,"title":3},"656":{"body":568,"breadcrumbs":5,"title":2},"657":{"body":50,"breadcrumbs":5,"title":2},"658":{"body":74,"breadcrumbs":5,"title":2},"659":{"body":0,"breadcrumbs":6,"title":3},"66":{"body":22,"breadcrumbs":4,"title":2},"660":{"body":49,"breadcrumbs":5,"title":2},"661":{"body":222,"breadcrumbs":6,"title":3},"662":{"body":198,"breadcrumbs":6,"title":3},"663":{"body":225,"breadcrumbs":7,"title":4},"664":{"body":157,"breadcrumbs":6,"title":3},"665":{"body":175,"breadcrumbs":6,"title":3},"666":{"body":106,"breadcrumbs":5,"title":2},"667":{"body":97,"breadcrumbs":5,"title":2},"668":{"body":284,"breadcrumbs":6,"title":3},"669":{"body":214,"breadcrumbs":7,"title":4},"67":{"body":10,"breadcrumbs":4,"title":2},"670":{"body":75,"breadcrumbs":7,"title":4},"671":{"body":161,"breadcrumbs":6,"title":3},"672":{"body":48,"breadcrumbs":6,"title":3},"673":{"body":64,"breadcrumbs":5,"title":2},"674":{"body":36,"breadcrumbs":7,"title":4},"675":{"body":76,"breadcrumbs":4,"title":1},"676":{"body":97,"breadcrumbs":5,"title":2},"677":{"body":114,"breadcrumbs":6,"title":3},"678":{"body":73,"breadcrumbs":5,"title":2},"679":{"body":91,"breadcrumbs":6,"title":3},"68":{"body":67,"breadcrumbs":4,"title":2},"680":{"body":178,"breadcrumbs":5,"title":2},"681":{"body":89,"breadcrumbs":6,"title":3},"682":{"body":69,"breadcrumbs":4,"title":1},"683":{"body":0,"breadcrumbs":5,"title":2},"684":{"body":170,"breadcrumbs":8,"title":5},"685":{"body":238,"breadcrumbs":7,"title":4},"686":{"body":81,"breadcrumbs":6,"title":3},"687":{"body":193,"breadcrumbs":7,"title":4},"688":{"body":81,"breadcrumbs":7,"title":4},"689":{"body":28,"breadcrumbs":6,"title":3},"69":{"body":28,"breadcrumbs":4,"title":2},"690":{"body":121,"breadcrumbs":6,"title":3},"691":{"body":0,"breadcrumbs":8,"title":4},"692":{"body":70,"breadcrumbs":7,"title":3},"693":{"body":28,"breadcrumbs":6,"title":2},"694":{"body":142,"breadcrumbs":10,"title":6},"695":{"body":39,"breadcrumbs":8,"title":4},"696":{"body":29,"breadcrumbs":6,"title":2},"697":{"body":0,"breadcrumbs":7,"title":4},"698":{"body":210,"breadcrumbs":6,"title":3},"699":{"body":164,"breadcrumbs":9,"title":6},"7":{"body":0,"breadcrumbs":5,"title":3},"70":{"body":10,"breadcrumbs":4,"title":2},"700":{"body":140,"breadcrumbs":5,"title":2},"701":{"body":127,"breadcrumbs":5,"title":2},"702":{"body":160,"breadcrumbs":6,"title":3},"703":{"body":98,"breadcrumbs":6,"title":3},"704":{"body":306,"breadcrumbs":7,"title":4},"705":{"body":257,"breadcrumbs":7,"title":4},"706":{"body":316,"breadcrumbs":8,"title":5},"707":{"body":255,"breadcrumbs":7,"title":4},"708":{"body":36,"breadcrumbs":5,"title":2},"709":{"body":0,"breadcrumbs":7,"title":4},"71":{"body":98,"breadcrumbs":4,"title":2},"710":{"body":68,"breadcrumbs":4,"title":1},"711":{"body":145,"breadcrumbs":5,"title":2},"712":{"body":115,"breadcrumbs":6,"title":3},"713":{"body":122,"breadcrumbs":9,"title":6},"714":{"body":84,"breadcrumbs":6,"title":3},"715":{"body":85,"breadcrumbs":5,"title":2},"716":{"body":21,"breadcrumbs":5,"title":2},"717":{"body":35,"breadcrumbs":5,"title":2},"718":{"body":78,"breadcrumbs":6,"title":3},"719":{"body":56,"breadcrumbs":6,"title":3},"72":{"body":99,"breadcrumbs":4,"title":2},"720":{"body":68,"breadcrumbs":5,"title":2},"721":{"body":0,"breadcrumbs":5,"title":3},"722":{"body":100,"breadcrumbs":6,"title":4},"723":{"body":28,"breadcrumbs":4,"title":2},"724":{"body":84,"breadcrumbs":6,"title":4},"725":{"body":55,"breadcrumbs":9,"title":7},"726":{"body":167,"breadcrumbs":5,"title":3},"727":{"body":109,"breadcrumbs":5,"title":3},"728":{"body":0,"breadcrumbs":6,"title":3},"729":{"body":256,"breadcrumbs":6,"title":3},"73":{"body":10,"breadcrumbs":4,"title":2},"730":{"body":0,"breadcrumbs":5,"title":2},"731":{"body":35,"breadcrumbs":4,"title":1},"732":{"body":49,"breadcrumbs":4,"title":1},"733":{"body":48,"breadcrumbs":4,"title":1},"734":{"body":247,"breadcrumbs":4,"title":1},"735":{"body":53,"breadcrumbs":4,"title":1},"736":{"body":99,"breadcrumbs":4,"title":1},"737":{"body":88,"breadcrumbs":4,"title":1},"738":{"body":66,"breadcrumbs":4,"title":1},"739":{"body":52,"breadcrumbs":4,"title":1},"74":{"body":41,"breadcrumbs":4,"title":2},"740":{"body":76,"breadcrumbs":6,"title":3},"741":{"body":0,"breadcrumbs":5,"title":2},"742":{"body":20,"breadcrumbs":7,"title":4},"743":{"body":19,"breadcrumbs":6,"title":3},"744":{"body":11,"breadcrumbs":5,"title":2},"745":{"body":38,"breadcrumbs":6,"title":3},"746":{"body":27,"breadcrumbs":6,"title":3},"747":{"body":0,"breadcrumbs":5,"title":2},"748":{"body":31,"breadcrumbs":5,"title":2},"749":{"body":23,"breadcrumbs":5,"title":2},"75":{"body":16,"breadcrumbs":4,"title":2},"750":{"body":23,"breadcrumbs":5,"title":2},"751":{"body":56,"breadcrumbs":5,"title":2},"752":{"body":46,"breadcrumbs":6,"title":3},"753":{"body":0,"breadcrumbs":3,"title":1},"754":{"body":70,"breadcrumbs":5,"title":3},"755":{"body":44,"breadcrumbs":5,"title":3},"756":{"body":77,"breadcrumbs":4,"title":2},"757":{"body":404,"breadcrumbs":6,"title":4},"758":{"body":60,"breadcrumbs":5,"title":3},"759":{"body":22,"breadcrumbs":5,"title":3},"76":{"body":129,"breadcrumbs":4,"title":2},"760":{"body":19,"breadcrumbs":6,"title":4},"761":{"body":87,"breadcrumbs":5,"title":3},"762":{"body":51,"breadcrumbs":5,"title":3},"763":{"body":57,"breadcrumbs":4,"title":2},"764":{"body":11,"breadcrumbs":7,"title":5},"765":{"body":0,"breadcrumbs":6,"title":4},"766":{"body":111,"breadcrumbs":5,"title":3},"767":{"body":110,"breadcrumbs":5,"title":3},"768":{"body":56,"breadcrumbs":5,"title":3},"769":{"body":41,"breadcrumbs":5,"title":3},"77":{"body":82,"breadcrumbs":4,"title":2},"770":{"body":0,"breadcrumbs":7,"title":5},"771":{"body":86,"breadcrumbs":6,"title":4},"772":{"body":61,"breadcrumbs":5,"title":3},"773":{"body":0,"breadcrumbs":6,"title":4},"774":{"body":190,"breadcrumbs":6,"title":4},"775":{"body":97,"breadcrumbs":7,"title":5},"776":{"body":119,"breadcrumbs":7,"title":5},"777":{"body":0,"breadcrumbs":6,"title":4},"778":{"body":79,"breadcrumbs":6,"title":4},"779":{"body":138,"breadcrumbs":12,"title":10},"78":{"body":130,"breadcrumbs":4,"title":2},"780":{"body":86,"breadcrumbs":6,"title":4},"781":{"body":0,"breadcrumbs":7,"title":5},"782":{"body":52,"breadcrumbs":7,"title":5},"783":{"body":89,"breadcrumbs":6,"title":4},"784":{"body":46,"breadcrumbs":5,"title":3},"785":{"body":19,"breadcrumbs":6,"title":4},"786":{"body":87,"breadcrumbs":8,"title":6},"787":{"body":113,"breadcrumbs":6,"title":4},"788":{"body":73,"breadcrumbs":5,"title":3},"789":{"body":36,"breadcrumbs":5,"title":3},"79":{"body":9,"breadcrumbs":4,"title":2},"790":{"body":26,"breadcrumbs":7,"title":5},"791":{"body":85,"breadcrumbs":5,"title":3},"792":{"body":188,"breadcrumbs":8,"title":6},"793":{"body":23,"breadcrumbs":7,"title":5},"794":{"body":183,"breadcrumbs":7,"title":5},"795":{"body":187,"breadcrumbs":11,"title":9},"796":{"body":27,"breadcrumbs":4,"title":2},"797":{"body":133,"breadcrumbs":7,"title":5},"798":{"body":168,"breadcrumbs":7,"title":5},"799":{"body":31,"breadcrumbs":8,"title":6},"8":{"body":742,"breadcrumbs":6,"title":4},"80":{"body":15,"breadcrumbs":4,"title":2},"800":{"body":47,"breadcrumbs":4,"title":2},"801":{"body":70,"breadcrumbs":5,"title":3},"802":{"body":30,"breadcrumbs":5,"title":3},"803":{"body":146,"breadcrumbs":6,"title":4},"804":{"body":70,"breadcrumbs":6,"title":4},"805":{"body":210,"breadcrumbs":8,"title":6},"806":{"body":60,"breadcrumbs":5,"title":3},"807":{"body":0,"breadcrumbs":5,"title":3},"808":{"body":88,"breadcrumbs":5,"title":3},"809":{"body":200,"breadcrumbs":9,"title":7},"81":{"body":17,"breadcrumbs":4,"title":2},"810":{"body":286,"breadcrumbs":6,"title":4},"811":{"body":75,"breadcrumbs":6,"title":4},"812":{"body":39,"breadcrumbs":6,"title":4},"813":{"body":96,"breadcrumbs":5,"title":3},"814":{"body":0,"breadcrumbs":6,"title":4},"815":{"body":144,"breadcrumbs":5,"title":3},"816":{"body":63,"breadcrumbs":5,"title":3},"817":{"body":0,"breadcrumbs":7,"title":5},"818":{"body":69,"breadcrumbs":7,"title":5},"819":{"body":237,"breadcrumbs":10,"title":8},"82":{"body":52,"breadcrumbs":4,"title":2},"820":{"body":0,"breadcrumbs":7,"title":5},"821":{"body":44,"breadcrumbs":7,"title":5},"822":{"body":27,"breadcrumbs":5,"title":3},"823":{"body":44,"breadcrumbs":5,"title":3},"824":{"body":0,"breadcrumbs":7,"title":5},"825":{"body":65,"breadcrumbs":5,"title":3},"826":{"body":48,"breadcrumbs":6,"title":4},"827":{"body":0,"breadcrumbs":6,"title":4},"828":{"body":81,"breadcrumbs":5,"title":3},"829":{"body":37,"breadcrumbs":5,"title":3},"83":{"body":87,"breadcrumbs":4,"title":2},"830":{"body":35,"breadcrumbs":6,"title":4},"831":{"body":0,"breadcrumbs":6,"title":4},"832":{"body":83,"breadcrumbs":5,"title":3},"833":{"body":25,"breadcrumbs":5,"title":3},"834":{"body":15,"breadcrumbs":4,"title":2},"835":{"body":105,"breadcrumbs":4,"title":2},"836":{"body":76,"breadcrumbs":4,"title":2},"837":{"body":136,"breadcrumbs":3,"title":1},"838":{"body":43,"breadcrumbs":4,"title":2},"839":{"body":144,"breadcrumbs":5,"title":3},"84":{"body":77,"breadcrumbs":4,"title":2},"840":{"body":9,"breadcrumbs":3,"title":1},"841":{"body":0,"breadcrumbs":3,"title":1},"842":{"body":0,"breadcrumbs":3,"title":1},"843":{"body":6,"breadcrumbs":4,"title":2},"844":{"body":0,"breadcrumbs":3,"title":1},"845":{"body":0,"breadcrumbs":4,"title":2},"846":{"body":38,"breadcrumbs":4,"title":2},"847":{"body":0,"breadcrumbs":3,"title":1},"848":{"body":0,"breadcrumbs":3,"title":1},"849":{"body":0,"breadcrumbs":4,"title":2},"85":{"body":82,"breadcrumbs":4,"title":2},"850":{"body":57,"breadcrumbs":6,"title":3},"851":{"body":123,"breadcrumbs":6,"title":3},"852":{"body":53,"breadcrumbs":5,"title":2},"853":{"body":27,"breadcrumbs":5,"title":2},"854":{"body":57,"breadcrumbs":4,"title":1},"855":{"body":58,"breadcrumbs":5,"title":2},"856":{"body":45,"breadcrumbs":9,"title":6},"857":{"body":45,"breadcrumbs":4,"title":1},"858":{"body":45,"breadcrumbs":4,"title":1},"859":{"body":0,"breadcrumbs":4,"title":1},"86":{"body":51,"breadcrumbs":4,"title":2},"860":{"body":85,"breadcrumbs":8,"title":5},"861":{"body":226,"breadcrumbs":9,"title":6},"862":{"body":113,"breadcrumbs":8,"title":5},"863":{"body":46,"breadcrumbs":8,"title":5},"864":{"body":69,"breadcrumbs":7,"title":4},"865":{"body":63,"breadcrumbs":9,"title":6},"866":{"body":109,"breadcrumbs":7,"title":4},"867":{"body":67,"breadcrumbs":4,"title":1},"868":{"body":89,"breadcrumbs":6,"title":3},"869":{"body":33,"breadcrumbs":5,"title":2},"87":{"body":22,"breadcrumbs":4,"title":2},"870":{"body":13,"breadcrumbs":5,"title":2},"871":{"body":23,"breadcrumbs":5,"title":2},"872":{"body":2,"breadcrumbs":4,"title":1},"873":{"body":12,"breadcrumbs":4,"title":1},"874":{"body":35,"breadcrumbs":5,"title":2},"875":{"body":121,"breadcrumbs":11,"title":7},"876":{"body":56,"breadcrumbs":5,"title":1},"877":{"body":198,"breadcrumbs":5,"title":1},"878":{"body":93,"breadcrumbs":8,"title":4},"879":{"body":161,"breadcrumbs":6,"title":2},"88":{"body":16,"breadcrumbs":4,"title":2},"880":{"body":7,"breadcrumbs":5,"title":1},"881":{"body":71,"breadcrumbs":6,"title":2},"882":{"body":54,"breadcrumbs":5,"title":1},"883":{"body":19,"breadcrumbs":5,"title":1},"884":{"body":135,"breadcrumbs":5,"title":1},"885":{"body":18,"breadcrumbs":6,"title":2},"886":{"body":197,"breadcrumbs":8,"title":4},"887":{"body":127,"breadcrumbs":10,"title":6},"888":{"body":157,"breadcrumbs":9,"title":5},"889":{"body":539,"breadcrumbs":6,"title":2},"89":{"body":27,"breadcrumbs":4,"title":2},"890":{"body":41,"breadcrumbs":6,"title":2},"891":{"body":142,"breadcrumbs":7,"title":3},"892":{"body":151,"breadcrumbs":6,"title":2},"893":{"body":133,"breadcrumbs":6,"title":2},"894":{"body":61,"breadcrumbs":5,"title":1},"895":{"body":14,"breadcrumbs":5,"title":1},"896":{"body":96,"breadcrumbs":6,"title":2},"897":{"body":89,"breadcrumbs":9,"title":5},"898":{"body":49,"breadcrumbs":5,"title":1},"899":{"body":137,"breadcrumbs":5,"title":1},"9":{"body":0,"breadcrumbs":5,"title":3},"90":{"body":9,"breadcrumbs":4,"title":2},"900":{"body":77,"breadcrumbs":6,"title":2},"901":{"body":164,"breadcrumbs":6,"title":2},"902":{"body":69,"breadcrumbs":5,"title":1},"903":{"body":0,"breadcrumbs":5,"title":1},"904":{"body":97,"breadcrumbs":5,"title":1},"905":{"body":61,"breadcrumbs":6,"title":2},"906":{"body":97,"breadcrumbs":5,"title":1},"907":{"body":49,"breadcrumbs":6,"title":2},"908":{"body":92,"breadcrumbs":6,"title":2},"909":{"body":60,"breadcrumbs":5,"title":1},"91":{"body":9,"breadcrumbs":4,"title":2},"910":{"body":45,"breadcrumbs":5,"title":1},"911":{"body":56,"breadcrumbs":6,"title":2},"912":{"body":61,"breadcrumbs":8,"title":5},"913":{"body":50,"breadcrumbs":4,"title":1},"914":{"body":193,"breadcrumbs":4,"title":1},"915":{"body":119,"breadcrumbs":5,"title":2},"916":{"body":0,"breadcrumbs":4,"title":1},"917":{"body":51,"breadcrumbs":8,"title":5},"918":{"body":37,"breadcrumbs":9,"title":6},"919":{"body":79,"breadcrumbs":6,"title":3},"92":{"body":9,"breadcrumbs":4,"title":2},"920":{"body":75,"breadcrumbs":10,"title":7},"921":{"body":42,"breadcrumbs":5,"title":2},"922":{"body":131,"breadcrumbs":5,"title":2},"923":{"body":97,"breadcrumbs":4,"title":1},"924":{"body":63,"breadcrumbs":4,"title":1},"925":{"body":107,"breadcrumbs":5,"title":2},"926":{"body":62,"breadcrumbs":9,"title":5},"927":{"body":51,"breadcrumbs":5,"title":1},"928":{"body":77,"breadcrumbs":5,"title":1},"929":{"body":88,"breadcrumbs":6,"title":2},"93":{"body":101,"breadcrumbs":4,"title":2},"930":{"body":0,"breadcrumbs":5,"title":1},"931":{"body":97,"breadcrumbs":6,"title":2},"932":{"body":70,"breadcrumbs":5,"title":1},"933":{"body":66,"breadcrumbs":5,"title":1},"934":{"body":147,"breadcrumbs":7,"title":3},"935":{"body":82,"breadcrumbs":6,"title":2},"936":{"body":26,"breadcrumbs":6,"title":2},"937":{"body":94,"breadcrumbs":6,"title":2},"938":{"body":70,"breadcrumbs":5,"title":1},"939":{"body":40,"breadcrumbs":5,"title":1},"94":{"body":41,"breadcrumbs":4,"title":2},"940":{"body":54,"breadcrumbs":6,"title":2},"941":{"body":46,"breadcrumbs":8,"title":6},"942":{"body":40,"breadcrumbs":3,"title":1},"943":{"body":0,"breadcrumbs":3,"title":1},"944":{"body":65,"breadcrumbs":4,"title":2},"945":{"body":25,"breadcrumbs":4,"title":2},"946":{"body":0,"breadcrumbs":3,"title":1},"947":{"body":116,"breadcrumbs":5,"title":3},"948":{"body":130,"breadcrumbs":6,"title":4},"949":{"body":27,"breadcrumbs":4,"title":2},"95":{"body":10,"breadcrumbs":4,"title":2},"950":{"body":26,"breadcrumbs":4,"title":2},"951":{"body":112,"breadcrumbs":4,"title":2},"952":{"body":77,"breadcrumbs":3,"title":1},"953":{"body":16,"breadcrumbs":3,"title":1},"954":{"body":61,"breadcrumbs":4,"title":2},"955":{"body":131,"breadcrumbs":15,"title":11},"956":{"body":52,"breadcrumbs":5,"title":1},"957":{"body":86,"breadcrumbs":5,"title":1},"958":{"body":134,"breadcrumbs":6,"title":2},"959":{"body":0,"breadcrumbs":5,"title":1},"96":{"body":9,"breadcrumbs":4,"title":2},"960":{"body":52,"breadcrumbs":9,"title":5},"961":{"body":20,"breadcrumbs":9,"title":5},"962":{"body":77,"breadcrumbs":9,"title":5},"963":{"body":102,"breadcrumbs":10,"title":6},"964":{"body":105,"breadcrumbs":9,"title":5},"965":{"body":13,"breadcrumbs":5,"title":1},"966":{"body":65,"breadcrumbs":6,"title":2},"967":{"body":115,"breadcrumbs":6,"title":2},"968":{"body":64,"breadcrumbs":5,"title":1},"969":{"body":45,"breadcrumbs":5,"title":1},"97":{"body":21,"breadcrumbs":4,"title":2},"970":{"body":54,"breadcrumbs":6,"title":2},"971":{"body":129,"breadcrumbs":11,"title":9},"972":{"body":51,"breadcrumbs":3,"title":1},"973":{"body":32,"breadcrumbs":3,"title":1},"974":{"body":112,"breadcrumbs":5,"title":3},"975":{"body":0,"breadcrumbs":3,"title":1},"976":{"body":37,"breadcrumbs":4,"title":2},"977":{"body":34,"breadcrumbs":7,"title":5},"978":{"body":45,"breadcrumbs":8,"title":6},"979":{"body":34,"breadcrumbs":8,"title":6},"98":{"body":141,"breadcrumbs":4,"title":2},"980":{"body":101,"breadcrumbs":8,"title":6},"981":{"body":26,"breadcrumbs":4,"title":2},"982":{"body":94,"breadcrumbs":4,"title":2},"983":{"body":100,"breadcrumbs":3,"title":1},"984":{"body":34,"breadcrumbs":4,"title":2},"985":{"body":92,"breadcrumbs":11,"title":8},"986":{"body":122,"breadcrumbs":5,"title":2},"987":{"body":54,"breadcrumbs":6,"title":3},"988":{"body":23,"breadcrumbs":6,"title":3},"989":{"body":0,"breadcrumbs":4,"title":1},"99":{"body":9,"breadcrumbs":4,"title":2},"990":{"body":148,"breadcrumbs":10,"title":7},"991":{"body":95,"breadcrumbs":9,"title":6},"992":{"body":53,"breadcrumbs":9,"title":6},"993":{"body":138,"breadcrumbs":7,"title":4},"994":{"body":44,"breadcrumbs":7,"title":4},"995":{"body":28,"breadcrumbs":5,"title":2},"996":{"body":96,"breadcrumbs":5,"title":2},"997":{"body":80,"breadcrumbs":4,"title":1},"998":{"body":46,"breadcrumbs":4,"title":1},"999":{"body":90,"breadcrumbs":5,"title":2}},"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