-
-
Notifications
You must be signed in to change notification settings - Fork 384
Yandex Music: rotor session API, Wave Modes, user presets, library sync improvements #3606
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
MarvinSchenkel
merged 82 commits into
music-assistant:dev
from
trudenboy:upstream/yandex_music
Apr 28, 2026
Merged
Changes from all commits
Commits
Show all changes
82 commits
Select commit
Hold shift + click to select a range
92a6c86
feat(yandex_music): add QR authentication and token auto-refresh
trudenboy 9eea38b
feat(yandex_music): sync provider from ma-provider-yandex-music v2.7.2
github-actions[bot] 13d605d
feat(yandex_music): sync provider from ma-provider-yandex-music v2.7.2
github-actions[bot] 8c03ad6
feat(yandex_music): sync provider from ma-provider-yandex-music v2.8.0
github-actions[bot] aeb818a
feat(yandex_music): sync provider from ma-provider-yandex-music v2.8.0
github-actions[bot] e66dbe7
feat(yandex_music): sync provider from ma-provider-yandex-music v2.8.0
github-actions[bot] bd16eba
feat(yandex_music): sync provider from ma-provider-yandex-music v2.8.0
github-actions[bot] 6cb58db
Merge branch 'dev' into upstream/yandex_music
trudenboy 097a956
Merge branch 'dev' into upstream/yandex_music
trudenboy 5c89c3c
feat(yandex_music): sync provider from ma-provider-yandex-music v2.8.0
github-actions[bot] 139a75f
feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.0
github-actions[bot] cc48936
feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.0
github-actions[bot] 86cc63c
chore: regenerate requirements_all.txt
trudenboy d4160dd
Merge branch 'dev' into upstream/yandex_music
trudenboy 7e33d1e
feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.0
github-actions[bot] e84549b
Merge branch 'dev' into upstream/yandex_music
trudenboy 4cbb976
feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.0
github-actions[bot] cf5e8cc
Merge branch 'dev' into upstream/yandex_music
trudenboy 2d72c82
Merge branch 'dev' into upstream/yandex_music
trudenboy 8101c40
feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.0
github-actions[bot] 4343118
feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.0
github-actions[bot] ae05b14
Merge branch 'dev' into upstream/yandex_music
trudenboy 8d81717
feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.0
github-actions[bot] ed76724
Merge branch 'dev' into upstream/yandex_music
trudenboy fb45738
feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.1
github-actions[bot] 431fe98
feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.0
github-actions[bot] 917ecc0
feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.0
github-actions[bot] c98604d
Merge branch 'dev' into upstream/yandex_music
trudenboy 496ee52
Merge branch 'dev' into upstream/yandex_music
trudenboy faebce6
feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.0
github-actions[bot] f4c1642
feat(kion_music): sync provider from ma-provider-kion-music v2.6.7
github-actions[bot] 7024760
feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.0
github-actions[bot] d8deb2c
Merge branch 'dev' into upstream/yandex_music
trudenboy 99f76c7
Remove kion_music provider from yandex_music PR branch
trudenboy 66acb7b
Restore kion_music to upstream/dev state
trudenboy 8ab1309
feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.0
github-actions[bot] c28c7ad
Bump kion_music yandex-music requirement to 3.0.0
trudenboy 3376fbf
Revert "Bump kion_music yandex-music requirement to 3.0.0"
trudenboy 94015a7
Merge branch 'dev' into upstream/yandex_music
trudenboy 0bb21a4
feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.0
github-actions[bot] 5577981
Merge branch 'dev' into upstream/yandex_music
trudenboy bd801d6
Update ya-passport-auth and yandex-music versions
trudenboy b566e91
Merge branch 'dev' into upstream/yandex_music
trudenboy ff3c031
Merge branch 'dev' into upstream/yandex_music
trudenboy d7b0644
feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.1
github-actions[bot] e4187b9
feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.1
github-actions[bot] d4df765
Merge branch 'dev' into upstream/yandex_music
trudenboy 67f1d51
feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.1
github-actions[bot] 72e1d05
feat(yandex_music): sync provider from ma-provider-yandex-music v3.1.0
github-actions[bot] b798cd1
Merge branch 'dev' into upstream/yandex_music
trudenboy e64d861
feat(yandex_music): sync provider from ma-provider-yandex-music v3.1.1
github-actions[bot] ed104c1
Merge branch 'dev' into upstream/yandex_music
trudenboy 3612ae7
feat(yandex_music): sync provider from ma-provider-yandex-music v3.1.2
github-actions[bot] 826dc09
Merge branch 'dev' into upstream/yandex_music
trudenboy 681e0ba
feat(yandex_music): sync provider from ma-provider-yandex-music v3.2.0
github-actions[bot] cd6cd2f
Merge branch 'dev' into upstream/yandex_music
trudenboy dc9b7cf
feat(yandex_music): sync provider from ma-provider-yandex-music v3.2.1
github-actions[bot] be93423
feat(yandex_music): sync provider from ma-provider-yandex-music v3.3.0
github-actions[bot] e5a70ce
Merge branch 'dev' into upstream/yandex_music
trudenboy c172f24
feat(yandex_music): sync provider from ma-provider-yandex-music v3.3.1
github-actions[bot] 1fa8115
Merge branch 'dev' into upstream/yandex_music
trudenboy 869ef78
feat(yandex_music): sync provider from ma-provider-yandex-music v3.4.0
github-actions[bot] db23b01
Merge branch 'dev' into upstream/yandex_music
trudenboy 3b439fd
feat(yandex_music): sync provider from ma-provider-yandex-music v3.4.0
github-actions[bot] 9614f95
Remove gql dependency from requirements
trudenboy be4d0f9
Update requirements_all.txt to remove warning
trudenboy be76923
Remove ya-passport-auth from requirements
trudenboy 068417e
Remove yandex-music dependency from requirements
trudenboy 43538d5
fix(requirements): restore requirements_all.txt via gen_requirements_…
trudenboy 6048467
feat(yandex_music): sync provider from ma-provider-yandex-music v3.4.1
github-actions[bot] 785fc5e
chore: remove tests/providers/kion_music/test_integration.py
trudenboy 093128a
chore: regenerate requirements_all.txt (yandex-music 3.0.0)
trudenboy 6d934be
Revert "chore: regenerate requirements_all.txt (yandex-music 3.0.0)"
trudenboy 95edc90
Merge branch 'dev' into upstream/yandex_music
trudenboy d1f502a
Merge branch 'dev' into upstream/yandex_music
trudenboy e9ce07a
fix(scripts): sort provider dirs in gen_requirements_all for determin…
trudenboy 0a2c6a2
Revert "fix(scripts): sort provider dirs in gen_requirements_all for …
trudenboy 8200f5c
chore: pin yandex-music==3.0.0 in requirements_all.txt to match CI re…
trudenboy be10725
chore: flip yandex-music pin back to 2.2.0 (CI inode order shifted ag…
trudenboy f4ee6cc
Merge branch 'dev' into upstream/yandex_music
trudenboy 7f9bdc8
Merge branch 'dev' into upstream/yandex_music
trudenboy cc5e670
Merge branch 'dev' into upstream/yandex_music
trudenboy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,362 @@ | ||
| """Yandex Music authentication flows. | ||
|
|
||
| Two user-facing login paths, both backed by ``ya-passport-auth``: | ||
|
|
||
| * **QR flow** — :func:`perform_qr_auth` opens a QR popup via the MA frontend | ||
| and polls Passport until the user scans/confirms. Yields | ||
| ``(x_token, music_token)``. | ||
| * **Device Flow** — :func:`perform_device_auth` serves a short user code on | ||
| an MA-hosted intermediate page and polls Passport until confirmation. | ||
| Yields the full ``(x_token, music_token, refresh_token)`` triple thanks | ||
| to ``ya-passport-auth`` v1.3.0 reusing the same Passport Android | ||
| ``client_id`` as the QR flow. | ||
|
|
||
| Token maintenance helpers (:func:`refresh_music_token`, | ||
| :func:`refresh_credentials_via_passport`, :func:`validate_x_token`) live | ||
| alongside the login flows. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import html | ||
| import json | ||
| import logging | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| from aiohttp import web | ||
| from music_assistant_models.errors import LoginFailed, ResourceTemporarilyUnavailable | ||
| from ya_passport_auth import Credentials, PassportClient, SecretStr | ||
| from ya_passport_auth.exceptions import ( | ||
| DeviceCodeTimeoutError, | ||
| NetworkError, | ||
| QRTimeoutError, | ||
| RateLimitedError, | ||
| YaPassportError, | ||
| ) | ||
|
|
||
| from music_assistant.helpers.auth import AuthenticationHelper | ||
|
|
||
| if TYPE_CHECKING: | ||
| from music_assistant import MusicAssistant | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| _DEVICE_CODE_PAGE_PATH = "/yandex_music/device_code" | ||
| # Seconds to keep the status endpoint alive after the flow finishes so the | ||
| # intermediate page has a chance to poll once more and close itself. | ||
| _POST_AUTH_GRACE_SECONDS = 3 | ||
|
|
||
|
|
||
| def _build_device_code_page( | ||
| user_code: str, | ||
| verification_url: str, | ||
| status_url: str, | ||
| ) -> str: | ||
| """Render the HTML page shown to the user during Device Flow login. | ||
|
|
||
| Yandex's verification page does not pre-fill the code from query params, | ||
| and the MA frontend opens auth URLs in a new tab, so the user would | ||
| otherwise have no signal that authorization succeeded. The page polls the | ||
| status endpoint and closes itself (or shows a success message) when the | ||
| backend signals completion. | ||
| """ | ||
| safe_code = html.escape(user_code) | ||
| safe_url = html.escape(verification_url, quote=True) | ||
| # json.dumps emits a JS string literal, but `</script>` would still break | ||
| # out of the surrounding <script> block. Escape the slash to be safe. | ||
| safe_status_url = json.dumps(status_url).replace("</", "<\\/") | ||
| return f"""<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <title>Yandex Music — Device Code</title> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <style> | ||
| :root {{ color-scheme: light; }} | ||
| body {{ | ||
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | ||
| margin: 0; padding: 2rem 1rem; | ||
| display: flex; align-items: center; justify-content: center; | ||
| min-height: 100vh; box-sizing: border-box; | ||
| background: #f5f5f7; color: #1d1d1f; | ||
| }} | ||
| .card {{ | ||
| background: #ffffff; color: #1d1d1f; | ||
| border-radius: 14px; padding: 2rem; | ||
| max-width: 28rem; width: 100%; | ||
| box-shadow: 0 4px 20px rgba(0,0,0,.08); | ||
| text-align: center; | ||
| }} | ||
| h1 {{ margin: 0 0 .5rem; font-size: 1.25rem; color: #1d1d1f; }} | ||
| p {{ margin: .5rem 0 1.25rem; color: #4a4a52; line-height: 1.45; }} | ||
| #code {{ | ||
| display: inline-block; | ||
| font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | ||
| font-size: 2rem; font-weight: 600; letter-spacing: .15em; | ||
| padding: .75rem 1.25rem; border-radius: 10px; | ||
| background: #f2f2f7; color: #1d1d1f; | ||
| user-select: all; | ||
| }} | ||
| button, .btn {{ | ||
| display: inline-block; margin-top: 1.5rem; padding: .75rem 1.5rem; | ||
| font-size: 1rem; font-weight: 600; text-decoration: none; | ||
| border: none; border-radius: 10px; cursor: pointer; | ||
| background: #ffcc00; color: #1d1d1f; | ||
| }} | ||
| button:hover, .btn:hover {{ background: #ffd633; }} | ||
| #copy {{ | ||
| margin-top: .75rem; background: transparent; color: #1d1d1f; | ||
| border: 1px solid #c8c8cd; padding: .4rem 1rem; | ||
| font-size: .85rem; font-weight: 400; | ||
| }} | ||
| #copy:hover {{ background: #f2f2f7; }} | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div class="card" id="card"> | ||
| <h1>Login to Yandex Music</h1> | ||
| <p>Open the link below and enter this code to authorize Music Assistant.</p> | ||
| <div id="code">{safe_code}</div> | ||
| <div> | ||
| <button id="copy" type="button">Copy code</button> | ||
| </div> | ||
| <a class="btn" href="{safe_url}" target="_blank" rel="noopener">Continue to Yandex</a> | ||
| </div> | ||
| <script> | ||
| const copyButton = document.getElementById('copy'); | ||
| const codeElement = document.getElementById('code'); | ||
| const card = document.getElementById('card'); | ||
| const statusUrl = {safe_status_url}; | ||
|
|
||
| function selectCodeForManualCopy() {{ | ||
| if (!codeElement) return; | ||
| const selection = window.getSelection(); | ||
| const range = document.createRange(); | ||
| range.selectNodeContents(codeElement); | ||
| selection.removeAllRanges(); | ||
| selection.addRange(range); | ||
| if (copyButton) copyButton.textContent = 'Press Ctrl/Cmd+C'; | ||
| }} | ||
|
|
||
| copyButton?.addEventListener('click', async function() {{ | ||
| const code = codeElement?.textContent?.trim(); | ||
| if (!code) return; | ||
| if (!navigator.clipboard?.writeText) {{ | ||
| selectCodeForManualCopy(); | ||
| return; | ||
| }} | ||
| try {{ | ||
| await navigator.clipboard.writeText(code); | ||
| this.textContent = 'Copied'; | ||
| }} catch {{ | ||
| selectCodeForManualCopy(); | ||
| }} | ||
| }}); | ||
|
|
||
| function showResult(title, message) {{ | ||
| card.innerHTML = | ||
| '<h1>' + title + '</h1><p>' + message + '</p>'; | ||
| }} | ||
|
|
||
| async function pollStatus() {{ | ||
| try {{ | ||
| const r = await fetch(statusUrl, {{ cache: 'no-store' }}); | ||
| if (r.ok) {{ | ||
| const data = await r.json(); | ||
| if (data.state === 'done') {{ | ||
| showResult( | ||
| 'Authorization successful', | ||
| 'You can close this window.' | ||
| ); | ||
| setTimeout(() => {{ try {{ window.close(); }} catch (e) {{}} }}, 300); | ||
| return; | ||
| }} | ||
| if (data.state === 'failed') {{ | ||
| showResult( | ||
| 'Authorization failed', | ||
| 'Please return to Music Assistant and try again.' | ||
| ); | ||
| return; | ||
| }} | ||
| }} | ||
| }} catch (e) {{ /* network hiccup — retry */ }} | ||
| setTimeout(pollStatus, 2000); | ||
| }} | ||
| setTimeout(pollStatus, 2000); | ||
| </script> | ||
| </body> | ||
| </html> | ||
| """ | ||
|
|
||
|
|
||
| async def perform_device_auth(mass: MusicAssistant, session_id: str) -> tuple[str, str, str]: | ||
| """Perform Yandex OAuth Device Flow and return credential tokens. | ||
|
|
||
| Asks Yandex for a device code, presents it to the user via an intermediate | ||
| HTML page served from MA's own webserver, then polls until the user | ||
| confirms or the code expires. | ||
|
|
||
| Returns (x_token, music_token, refresh_token) as plain strings for MA | ||
| config storage. | ||
| """ | ||
| try: | ||
| async with PassportClient.create() as client: | ||
| session = await client.start_device_login() | ||
|
|
||
| _LOGGER.info( | ||
| "Device flow started: open %s (expires in %ss)", | ||
| session.verification_url, | ||
| session.expires_in, | ||
| ) | ||
| _LOGGER.debug("Device flow user_code issued") | ||
|
|
||
| page_path = f"{_DEVICE_CODE_PAGE_PATH}/{session_id}" | ||
| status_path = f"{page_path}/status" | ||
| status_url = f"{mass.webserver.base_url}{status_path}" | ||
| state = {"value": "pending"} | ||
|
|
||
| page_html = _build_device_code_page( | ||
| session.user_code, session.verification_url, status_url | ||
| ) | ||
|
|
||
| async def _serve_page(_request: web.Request) -> web.Response: | ||
| return web.Response( | ||
| text=page_html, | ||
| content_type="text/html", | ||
| charset="utf-8", | ||
| headers={ | ||
| "Cache-Control": "no-store", | ||
| "Pragma": "no-cache", | ||
| "Expires": "0", | ||
| }, | ||
| ) | ||
|
|
||
| async def _serve_status(_request: web.Request) -> web.Response: | ||
| return web.json_response( | ||
| {"state": state["value"]}, | ||
| headers={"Cache-Control": "no-store"}, | ||
| ) | ||
|
|
||
| mass.webserver.register_dynamic_route(page_path, _serve_page, "GET") | ||
| mass.webserver.register_dynamic_route(status_path, _serve_status, "GET") | ||
| try: | ||
| async with AuthenticationHelper(mass, session_id) as auth_helper: | ||
| auth_helper.send_url(f"{mass.webserver.base_url}{page_path}") | ||
| try: | ||
| creds = await client.poll_device_until_confirmed(session) | ||
| except asyncio.CancelledError: | ||
| # Don't mark cancellations as auth failures. | ||
| raise | ||
| except Exception: | ||
| state["value"] = "failed" | ||
| # Give the page one more poll to surface the failure | ||
| # message before we tear the status route down. | ||
| await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) | ||
| raise | ||
| state["value"] = "done" | ||
| # Give the intermediate page one more poll to pick up "done" | ||
| # and close itself before we tear the status route down. | ||
| await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) | ||
| finally: | ||
| mass.webserver.unregister_dynamic_route(page_path, "GET") | ||
| mass.webserver.unregister_dynamic_route(status_path, "GET") | ||
|
|
||
| music_token = creds.music_token | ||
| if music_token is None: | ||
| raise LoginFailed("Device auth succeeded but no music token was returned") | ||
| refresh_token = creds.refresh_token | ||
| if refresh_token is None: | ||
| raise LoginFailed("Device auth succeeded but no refresh token was returned") | ||
|
|
||
| _LOGGER.debug("Device flow complete, obtained full credential triple") | ||
| return ( | ||
| creds.x_token.get_secret(), | ||
| music_token.get_secret(), | ||
| refresh_token.get_secret(), | ||
| ) | ||
|
|
||
| except DeviceCodeTimeoutError as err: | ||
| raise LoginFailed("Device authentication timed out. Please try again.") from err | ||
| except YaPassportError as err: | ||
| raise LoginFailed(f"Yandex device auth error: {err}") from err | ||
|
|
||
|
|
||
| async def perform_qr_auth(mass: MusicAssistant, session_id: str) -> tuple[str, str]: | ||
| """Perform full QR authentication flow. | ||
|
|
||
| Opens a QR code popup via MA frontend, polls for scan confirmation, | ||
| then returns tokens as plain strings for MA config storage. | ||
|
|
||
| Returns (x_token, music_token). | ||
| """ | ||
| try: | ||
| async with PassportClient.create() as client: | ||
| qr = await client.start_qr_login() | ||
|
|
||
| async with AuthenticationHelper(mass, session_id) as auth_helper: | ||
| auth_helper.send_url(qr.qr_url) | ||
| creds = await client.poll_qr_until_confirmed(qr) | ||
|
|
||
| x_token = creds.x_token.get_secret() | ||
| music_token = creds.music_token | ||
| if music_token is None: | ||
| raise LoginFailed("QR auth succeeded but no music token was returned") | ||
|
|
||
| _LOGGER.debug("QR auth complete, obtained both tokens") | ||
| return x_token, music_token.get_secret() | ||
|
|
||
| except QRTimeoutError as err: | ||
| raise LoginFailed("QR authentication timed out. Please try again.") from err | ||
| except YaPassportError as err: | ||
| raise LoginFailed(f"Yandex auth error: {err}") from err | ||
|
|
||
|
|
||
| async def refresh_music_token(x_token: SecretStr) -> SecretStr: | ||
| """Exchange an x_token for a fresh music-scoped OAuth token. | ||
|
|
||
| Distinguishes transient Passport failures (network/rate limiting) from | ||
| credential-invalid errors: only the latter raise ``LoginFailed``, so | ||
| callers don't clear stored tokens on a Passport blip. | ||
| """ | ||
| try: | ||
| async with PassportClient.create() as client: | ||
| return await client.refresh_music_token(x_token) | ||
| except (NetworkError, RateLimitedError) as err: | ||
| raise ResourceTemporarilyUnavailable( | ||
| f"Yandex Passport temporarily unavailable: {err}" | ||
| ) from err | ||
| except YaPassportError as err: | ||
| raise LoginFailed(f"Failed to refresh music token: {err}") from err | ||
|
|
||
|
|
||
| async def refresh_credentials_via_passport( | ||
| x_token: SecretStr, refresh_token: SecretStr | ||
| ) -> Credentials: | ||
| """Silently re-issue the full credential triple using a refresh token. | ||
|
|
||
| Only available for accounts authenticated via the Device Flow (QR login | ||
| does not yield a ``refresh_token``). Rotates both ``x_token`` and | ||
| ``refresh_token`` server-side, so callers must persist the returned | ||
| Credentials. | ||
| """ | ||
| try: | ||
| async with PassportClient.create() as client: | ||
| return await client.refresh_credentials( | ||
| Credentials(x_token=x_token, refresh_token=refresh_token) | ||
| ) | ||
| except (NetworkError, RateLimitedError) as err: | ||
| raise ResourceTemporarilyUnavailable( | ||
| f"Yandex Passport temporarily unavailable: {err}" | ||
| ) from err | ||
| except YaPassportError as err: | ||
| raise LoginFailed(f"Failed to refresh credentials: {err}") from err | ||
|
trudenboy marked this conversation as resolved.
|
||
|
|
||
|
|
||
| async def validate_x_token(x_token: SecretStr) -> bool: | ||
| """Return True if *x_token* is still accepted by Yandex Passport.""" | ||
| try: | ||
| async with PassportClient.create() as client: | ||
| return bool(await client.validate_x_token(x_token)) | ||
| except YaPassportError: | ||
| return False | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.