Compare commits

..
27 Commits
Author SHA1 Message Date
Tobias Gesellchen e0a84d5904 Split register and unregister device tests (#133) 2026-03-27 22:03:21 +01:00
dependabot[bot]andlnx01 5d080cf35f ci(deps): bump codecov/codecov-action from 5 to 6 in the security-actions group (#132)
Bumps the security-actions group with 1 update:
[codecov/codecov-action](https://github.com/codecov/codecov-action).

Updates `codecov/codecov-action` from 5 to 6
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/codecov/codecov-action/releases">codecov/codecov-action's
releases</a>.</em></p>
<blockquote>
<h2>v6.0.0</h2>
<h2>⚠️ This version introduces support for node24 which make cause
breaking changes for systems that do not currently support node24.
⚠️</h2>
<h2>What's Changed</h2>
<ul>
<li>Revert &quot;Revert &quot;build(deps): bump actions/github-script
from 7.0.1 to 8.0.0&quot;&quot; by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1929">codecov/codecov-action#1929</a></li>
<li>Th/6.0.0 by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1928">codecov/codecov-action#1928</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.5.4...v6.0.0">https://github.com/codecov/codecov-action/compare/v5.5.4...v6.0.0</a></p>
<h2>v5.5.4</h2>
<p>This is a mirror of <code>v5.5.2</code>. <code>v6</code> will be
released which requires <code>node24</code></p>
<h2>What's Changed</h2>
<ul>
<li>Revert &quot;build(deps): bump actions/github-script from 7.0.1 to
8.0.0&quot; by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1926">codecov/codecov-action#1926</a></li>
<li>chore(release): 5.5.4 by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1927">codecov/codecov-action#1927</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.5.3...v5.5.4">https://github.com/codecov/codecov-action/compare/v5.5.3...v5.5.4</a></p>
<h2>v5.5.3</h2>
<h2>What's Changed</h2>
<ul>
<li>build(deps): bump actions/github-script from 7.0.1 to 8.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1874">codecov/codecov-action#1874</a></li>
<li>chore(release): bump to 5.5.3 by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1922">codecov/codecov-action#1922</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.5.2...v5.5.3">https://github.com/codecov/codecov-action/compare/v5.5.2...v5.5.3</a></p>
<h2>v5.5.2</h2>
<h2>What's Changed</h2>
<ul>
<li>check gpg only when skip-validation = false by <a
href="https://github.com/maxweng-sentry"><code>@​maxweng-sentry</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1894">codecov/codecov-action#1894</a></li>
<li>chore: <code>disable_search</code> alignment by <a
href="https://github.com/freemanzMrojo"><code>@​freemanzMrojo</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1881">codecov/codecov-action#1881</a></li>
<li>chore(release): 5.5.2 by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1902">codecov/codecov-action#1902</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/maxweng-sentry"><code>@​maxweng-sentry</code></a>
made their first contribution in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1894">codecov/codecov-action#1894</a></li>
<li><a
href="https://github.com/freemanzMrojo"><code>@​freemanzMrojo</code></a>
made their first contribution in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1881">codecov/codecov-action#1881</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.5.1...v5.5.2">https://github.com/codecov/codecov-action/compare/v5.5.1...v5.5.2</a></p>
<h2>v5.5.1</h2>
<h2>What's Changed</h2>
<ul>
<li>build(deps): bump ossf/scorecard-action from 2.4.1 to 2.4.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1833">codecov/codecov-action#1833</a></li>
<li>build(deps): bump github/codeql-action from 3.28.18 to 3.29.9 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1861">codecov/codecov-action#1861</a></li>
<li>Document a <code>codecov-cli</code> version reference example by <a
href="https://github.com/webknjaz"><code>@​webknjaz</code></a> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1774">codecov/codecov-action#1774</a></li>
<li>docs: fix typo in README by <a
href="https://github.com/datalater"><code>@​datalater</code></a> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1866">codecov/codecov-action#1866</a></li>
<li>fix: update to use local app/ dir by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1872">codecov/codecov-action#1872</a></li>
<li>build(deps): bump github/codeql-action from 3.29.9 to 3.29.11 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1867">codecov/codecov-action#1867</a></li>
<li>build(deps): bump actions/checkout from 4.2.2 to 5.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1868">codecov/codecov-action#1868</a></li>
<li>fix: overwrite pr number on fork by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1871">codecov/codecov-action#1871</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md">codecov/codecov-action's
changelog</a>.</em></p>
<blockquote>
<h2>v5.5.2</h2>
<h3>What's Changed</h3>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.5.1..v5.5.2">https://github.com/codecov/codecov-action/compare/v5.5.1..v5.5.2</a></p>
<h2>v5.5.1</h2>
<h3>What's Changed</h3>
<ul>
<li>fix: overwrite pr number on fork by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1871">codecov/codecov-action#1871</a></li>
<li>build(deps): bump actions/checkout from 4.2.2 to 5.0.0 by
<code>@​app/dependabot</code> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1868">codecov/codecov-action#1868</a></li>
<li>build(deps): bump github/codeql-action from 3.29.9 to 3.29.11 by
<code>@​app/dependabot</code> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1867">codecov/codecov-action#1867</a></li>
<li>fix: update to use local app/ dir by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1872">codecov/codecov-action#1872</a></li>
<li>docs: fix typo in README by <a
href="https://github.com/datalater"><code>@​datalater</code></a> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1866">codecov/codecov-action#1866</a></li>
<li>Document a <code>codecov-cli</code> version reference example by <a
href="https://github.com/webknjaz"><code>@​webknjaz</code></a> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1774">codecov/codecov-action#1774</a></li>
<li>build(deps): bump github/codeql-action from 3.28.18 to 3.29.9 by
<code>@​app/dependabot</code> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1861">codecov/codecov-action#1861</a></li>
<li>build(deps): bump ossf/scorecard-action from 2.4.1 to 2.4.2 by
<code>@​app/dependabot</code> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1833">codecov/codecov-action#1833</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.5.0..v5.5.1">https://github.com/codecov/codecov-action/compare/v5.5.0..v5.5.1</a></p>
<h2>v5.5.0</h2>
<h3>What's Changed</h3>
<ul>
<li>feat: upgrade wrapper to 0.2.4 by <a
href="https://github.com/jviall"><code>@​jviall</code></a> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1864">codecov/codecov-action#1864</a></li>
<li>Pin actions/github-script by Git SHA by <a
href="https://github.com/martincostello"><code>@​martincostello</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1859">codecov/codecov-action#1859</a></li>
<li>fix: check reqs exist by <a
href="https://github.com/joseph-sentry"><code>@​joseph-sentry</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1835">codecov/codecov-action#1835</a></li>
<li>fix: Typo in README by <a
href="https://github.com/spalmurray"><code>@​spalmurray</code></a> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1838">codecov/codecov-action#1838</a></li>
<li>docs: Refine OIDC docs by <a
href="https://github.com/spalmurray"><code>@​spalmurray</code></a> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1837">codecov/codecov-action#1837</a></li>
<li>build(deps): bump github/codeql-action from 3.28.17 to 3.28.18 by
<code>@​app/dependabot</code> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1829">codecov/codecov-action#1829</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.4.3..v5.5.0">https://github.com/codecov/codecov-action/compare/v5.4.3..v5.5.0</a></p>
<h2>v5.4.3</h2>
<h3>What's Changed</h3>
<ul>
<li>build(deps): bump github/codeql-action from 3.28.13 to 3.28.17 by
<code>@​app/dependabot</code> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1822">codecov/codecov-action#1822</a></li>
<li>fix: OIDC on forks by <a
href="https://github.com/joseph-sentry"><code>@​joseph-sentry</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1823">codecov/codecov-action#1823</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.4.2..v5.4.3">https://github.com/codecov/codecov-action/compare/v5.4.2..v5.4.3</a></p>
<h2>v5.4.2</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/codecov/codecov-action/commit/57e3a136b779b570ffcdbf80b3bdc90e7fab3de2"><code>57e3a13</code></a>
Th/6.0.0 (<a
href="https://redirect.github.com/codecov/codecov-action/issues/1928">#1928</a>)</li>
<li><a
href="https://github.com/codecov/codecov-action/commit/f67d33dda8a42b51c42a8318a1f66468119e898b"><code>f67d33d</code></a>
Revert &quot;Revert &quot;build(deps): bump actions/github-script from
7.0.1 to 8.0.0&quot;&quot;...</li>
<li>See full diff in <a
href="https://github.com/codecov/codecov-action/compare/v5...v6">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=codecov/codecov-action&package-manager=github_actions&previous-version=5&new-version=6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 08:47:00 +01:00
dependabot[bot]andlnx01 bcd383bdff ci(deps): bump actions/deploy-pages from 4 to 5 in the actions-core group (#131)
Bumps the actions-core group with 1 update:
[actions/deploy-pages](https://github.com/actions/deploy-pages).

Updates `actions/deploy-pages` from 4 to 5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/deploy-pages/releases">actions/deploy-pages's
releases</a>.</em></p>
<blockquote>
<h2>v5.0.0</h2>
<h1>Changelog</h1>
<ul>
<li>Update Node.js version to 24.x <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/404">#404</a>)</li>
<li>Add workflow file for publishing releases to immutable action
package <a
href="https://github.com/Jcambass"><code>@​Jcambass</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/374">#374</a>)</li>
<li>Bump braces from 3.0.2 to 3.0.3 in the npm_and_yarn group across 1
directory <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/360">#360</a>)</li>
<li>Make the rebuild dist workflow work nicer with Dependabot <a
href="https://github.com/yoannchaudet"><code>@​yoannchaudet</code></a>
(<a
href="https://redirect.github.com/actions/deploy-pages/issues/361">#361</a>)</li>
<li>Bump the non-breaking-changes group across 1 directory with 3
updates <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/358">#358</a>)</li>
<li>Delete repeated sentence <a
href="https://github.com/garethsb"><code>@​garethsb</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/359">#359</a>)</li>
<li>Update README.md <a
href="https://github.com/tsusdere"><code>@​tsusdere</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/348">#348</a>)</li>
<li>Bump the non-breaking-changes group with 4 updates <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/341">#341</a>)</li>
<li>Remove error message for file permissions <a
href="https://github.com/TooManyBees"><code>@​TooManyBees</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/340">#340</a>)</li>
</ul>
<hr />
<p>See details of <a
href="https://github.com/actions/deploy-pages/compare/v4.0.5...v4.0.6">all
code changes</a> since previous release.</p>
<p>⚠️ For use with products other than GitHub.com, such as GitHub
Enterprise Server, please consult the <a
href="https://github.com/actions/deploy-pages/#compatibility">compatibility
table</a>.</p>
<h2>v4.0.5</h2>
<h1>Changelog</h1>
<ul>
<li>On API error, the error message will surface the API request ID <a
href="https://github.com/TooManyBees"><code>@​TooManyBees</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/324">#324</a>)</li>
<li>Bump the non-breaking-changes group with 2 updates <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/318">#318</a>)</li>
<li>Bump the non-breaking-changes group with 1 update <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/316">#316</a>)</li>
<li>Bump the non-breaking-changes group with 3 updates <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/314">#314</a>)</li>
<li>Bump release-drafter/release-drafter from 5.25.0 to 6.0.0 <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/311">#311</a>)</li>
</ul>
<hr />
<p>See details of <a
href="https://github.com/actions/deploy-pages/compare/v4.0.4...v4.0.5">all
code changes</a> since previous release.</p>
<p>⚠️ For use with products other than GitHub.com, such as GitHub
Enterprise Server, please consult the <a
href="https://github.com/actions/deploy-pages/#compatibility">compatibility
table</a>.</p>
<h2>v4.0.4</h2>
<h1>Changelog</h1>
<ul>
<li>Update api-client.js <a
href="https://github.com/lmammino"><code>@​lmammino</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/295">#295</a>)</li>
<li>fix typo: compatibilty -&gt; compatibility <a
href="https://github.com/SimonSiefke"><code>@​SimonSiefke</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/298">#298</a>)</li>
<li>Bump <code>@​actions/artifact</code> from 2.0.1 to 2.1.1 <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/310">#310</a>)</li>
<li>Update Dependabot config to group non-breaking changes <a
href="https://github.com/JamesMGreene"><code>@​JamesMGreene</code></a>
(<a
href="https://redirect.github.com/actions/deploy-pages/issues/307">#307</a>)</li>
</ul>
<hr />
<p>See details of <a
href="https://github.com/actions/deploy-pages/compare/v4.0.3...v4.0.4">all
code changes</a> since previous release.</p>
<p>⚠️ For use with products other than GitHub.com, such as GitHub
Enterprise Server, please consult the <a
href="https://github.com/actions/deploy-pages/#compatibility">compatibility
table</a>.</p>
<h2>v4.0.3</h2>
<h1>Changelog</h1>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/deploy-pages/commit/cd2ce8fcbc39b97be8ca5fce6e763baed58fa128"><code>cd2ce8f</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/deploy-pages/issues/404">#404</a>
from salmanmkc/node24</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/bbe2a950ee52d4f5cbe74e6d9d6a8803676e91d5"><code>bbe2a95</code></a>
Update Node.js version to 24.x</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/854d7aa1b99e4509c4d1b53d69b7ba4eaf39215a"><code>854d7aa</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/deploy-pages/issues/374">#374</a>
from actions/Jcambass-patch-1</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/306bb814f29679fd12f0e4b0014bc1f3a7e7f4bc"><code>306bb81</code></a>
Add workflow file for publishing releases to immutable action
package</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/b74272834adc04f971da4b0b055c49fa8d7f90c9"><code>b742728</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/deploy-pages/issues/360">#360</a>
from actions/dependabot/npm_and_yarn/npm_and_yarn-513...</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/72732942c639e67ea3f70165fd2e012dd6d95027"><code>7273294</code></a>
Bump braces in the npm_and_yarn group across 1 directory</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/963791f01c40ef3eff219c255dbfb97a6f2c9f87"><code>963791f</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/deploy-pages/issues/361">#361</a>
from actions/dependabot-friendly</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/51bb29d9d7bfe15d731c4957ce1887b5ae8c6727"><code>51bb29d</code></a>
Make the rebuild dist workflow safer for Dependabot</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/89f3d10406f57ee86e6517a982b3fb0438bd6dc5"><code>89f3d10</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/deploy-pages/issues/358">#358</a>
from actions/dependabot/npm_and_yarn/non-breaking-cha...</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/bce735589bbbfa569f1d2ac003277b590d743e4c"><code>bce7355</code></a>
Merge branch 'main' into
dependabot/npm_and_yarn/non-breaking-changes-99c12deb21</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/deploy-pages/compare/v4...v5">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/deploy-pages&package-manager=github_actions&previous-version=4&new-version=5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 08:46:51 +01:00
dependabot[bot]andlnx01 7a09a2ddc0 deps(deps): bump golang.org/x/image from 0.37.0 to 0.38.0 in the golang group (#130)
Bumps the golang group with 1 update:
[golang.org/x/image](https://github.com/golang/image).

Updates `golang.org/x/image` from 0.37.0 to 0.38.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/image/commit/23ae9ed61c1d3343fb95015810f62dcbf444976e"><code>23ae9ed</code></a>
tiff: cap buffer growth to prevent OOM from malicious IFD offset</li>
<li><a
href="https://github.com/golang/image/commit/e589e60f29d0bbbf6400e250e024f93cbc4961ee"><code>e589e60</code></a>
webp: allow VP8L + VP8X(with alpha)</li>
<li>See full diff in <a
href="https://github.com/golang/image/compare/v0.37.0...v0.38.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golang.org/x/image&package-manager=go_modules&previous-version=0.37.0&new-version=0.38.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 08:46:41 +01:00
Tobias Gesellchen b04b0bcc32 Add account registration/login (#129) 2026-03-27 08:37:50 +01:00
Tobias GesellchenandJunie 61b5c71097 Enhance account overview UI and make fields editable (#127)
- Added detailed provider settings display to account overview
- Made 'Language' field editable with auto-save functionality (currently
only `en` and `de` available without actual effect on any UI or speaker
config)
- Made 'SPOTIFY - STREAMING_QUALITY' editable with descriptive quality
options
- ⚠️ this currently only writes the account config, but does not update
the actual speaker setting
- Improved account data persistence and error handling
- Added tests for new management API endpoints and data store changes

---------

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 11:42:58 +01:00
Tobias GesellchenandJunie 9f7cb81b45 Implement skip mirror endpoints to reduce false positives in parity checks (#126)
Added 'Skip Mirror Endpoints' setting to allow specific requests like
`/oauth/device/*/music/musicprovider/15/token/cs3` to be handled
exclusively locally, even when mirroring is enabled. Updated
MirrorMiddleware to check against the skip list before performing
mirroring or parity logic. Exposed the setting via the Web UI Settings
tab and the CLI. Updated relevant tests to accommodate the configuration
changes.

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 10:53:36 +01:00
Tobias GesellchenandJunie d5d6585517 Refactor hardcoded source provider IDs to use lookup from constants (#125)
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 10:25:06 +01:00
Tobias GesellchenandJunie 50b694aa08 Fix generic source names in Local Account UI by falling back to account name (#124)
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 09:48:07 +01:00
Tobias GesellchenandJunie 717693e01f feat(sync): improve parity with upstream during data sync (#123)
- Enhance initial and full data synchronization to better align with
upstream services.
- Update data structures in 'pkg/models' to support missing fields
(e.g., SecretType for Spotify).
- Improve 'datastore' persistence logic for presets, recents, and
sources.
- Add comprehensive regression tests for sync and datastore operations.
- Update documentation on parity status and improvements.

Co-authored-by: Junie <junie@jetbrains.com>

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 00:01:41 +01:00
Tobias Gesellchen a0833c113c Add favicon-gen tool to generate PNG and ICO favicons from SVG sources (#22) 2026-03-21 13:18:16 +01:00
Tobias GesellchenandJunie e74d2e0fc3 refactor(web): reorganize media assets and add logo to web UI
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-21 13:08:27 +01:00
Tobias GesellchenandJunie 5b642010d4 fix(bmx): use official Bose URL in registry when DNS is enabled
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-21 13:08:27 +01:00
Tobias GesellchenandJunie 5078d933d5 fix(mirror): prevent infinite loop in MirrorMiddleware
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-21 13:08:27 +01:00
Tobias GesellchenandJunie 6cf511e7e5 Implement local Bose Spotify OAuth token handling and fix linting issues in tests (#119)
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-18 23:22:32 +01:00
Tobias Gesellchenandlnx01 ba11394d0f Potential fix for code scanning alert no. 80: Uncontrolled data used in path expression
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-17 22:59:20 +01:00
Tobias GesellchenandJunie 37eb23fc36 Merge existing device info in SaveDeviceInfo to preserve name on power-on
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-17 22:59:20 +01:00
dependabot[bot]andlnx01 4544486221 ci(deps): bump docker/build-push-action from 6 to 7 (#117)
Bumps
[docker/build-push-action](https://github.com/docker/build-push-action)
from 6 to 7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/build-push-action/releases">docker/build-push-action's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<ul>
<li>Node 24 as default runtime (requires <a
href="https://github.com/actions/runner/releases/tag/v2.327.1">Actions
Runner v2.327.1</a> or later) by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1470">docker/build-push-action#1470</a></li>
<li>Remove deprecated <code>DOCKER_BUILD_NO_SUMMARY</code> and
<code>DOCKER_BUILD_EXPORT_RETENTION_DAYS</code> envs by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1473">docker/build-push-action#1473</a></li>
<li>Remove legacy export-build tool support for build summary by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1474">docker/build-push-action#1474</a></li>
<li>Switch to ESM and update config/test wiring by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1466">docker/build-push-action#1466</a></li>
<li>Bump <code>@​actions/core</code> from 1.11.1 to 3.0.0 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1454">docker/build-push-action#1454</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.62.1 to 0.79.0 in
<a
href="https://redirect.github.com/docker/build-push-action/pull/1453">docker/build-push-action#1453</a>
<a
href="https://redirect.github.com/docker/build-push-action/pull/1472">docker/build-push-action#1472</a>
<a
href="https://redirect.github.com/docker/build-push-action/pull/1479">docker/build-push-action#1479</a></li>
<li>Bump minimatch from 3.1.2 to 3.1.5 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1463">docker/build-push-action#1463</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v6.19.2...v7.0.0">https://github.com/docker/build-push-action/compare/v6.19.2...v7.0.0</a></p>
<h2>v6.19.2</h2>
<ul>
<li>Preserve port in <code>GIT_AUTH_TOKEN</code> host by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1458">docker/build-push-action#1458</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v6.19.1...v6.19.2">https://github.com/docker/build-push-action/compare/v6.19.1...v6.19.2</a></p>
<h2>v6.19.1</h2>
<ul>
<li>Derive <code>GIT_AUTH_TOKEN</code> host from GitHub server URL by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1456">docker/build-push-action#1456</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v6.19.0...v6.19.1">https://github.com/docker/build-push-action/compare/v6.19.0...v6.19.1</a></p>
<h2>v6.19.0</h2>
<ul>
<li>Scope default git auth token to <code>github.com</code> by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1451">docker/build-push-action#1451</a></li>
<li>Bump brace-expansion from 1.1.11 to 1.1.12 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1396">docker/build-push-action#1396</a></li>
<li>Bump form-data from 2.5.1 to 2.5.5 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1391">docker/build-push-action#1391</a></li>
<li>Bump js-yaml from 3.14.1 to 3.14.2 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1429">docker/build-push-action#1429</a></li>
<li>Bump lodash from 4.17.21 to 4.17.23 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1446">docker/build-push-action#1446</a></li>
<li>Bump tmp from 0.2.3 to 0.2.4 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1398">docker/build-push-action#1398</a></li>
<li>Bump undici from 5.28.4 to 5.29.0 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1397">docker/build-push-action#1397</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v6.18.0...v6.19.0">https://github.com/docker/build-push-action/compare/v6.18.0...v6.19.0</a></p>
<h2>v6.18.0</h2>
<ul>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.61.0 to 0.62.1 in
<a
href="https://redirect.github.com/docker/build-push-action/pull/1381">docker/build-push-action#1381</a></li>
</ul>
<blockquote>
<p>[!NOTE]
<a
href="https://docs.docker.com/build/ci/github-actions/build-summary/">Build
summary</a> is now supported with <a
href="https://docs.docker.com/build-cloud/">Docker Build Cloud</a>.</p>
</blockquote>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v6.17.0...v6.18.0">https://github.com/docker/build-push-action/compare/v6.17.0...v6.18.0</a></p>
<h2>v6.17.0</h2>
<ul>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.59.0 to 0.61.0 by
<a href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in
<a
href="https://redirect.github.com/docker/build-push-action/pull/1364">docker/build-push-action#1364</a></li>
</ul>
<blockquote>
<p>[!NOTE]
Build record is now exported using the <a
href="https://docs.docker.com/reference/cli/docker/buildx/history/export/"><code>buildx
history export</code></a> command instead of the legacy export-build
tool.</p>
</blockquote>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v6.16.0...v6.17.0">https://github.com/docker/build-push-action/compare/v6.16.0...v6.17.0</a></p>
<h2>v6.16.0</h2>
<ul>
<li>Handle no default attestations env var by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1343">docker/build-push-action#1343</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/build-push-action/commit/d08e5c354a6adb9ed34480a06d141179aa583294"><code>d08e5c3</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1479">#1479</a>
from docker/dependabot/npm_and_yarn/docker/actions-t...</li>
<li><a
href="https://github.com/docker/build-push-action/commit/cbd2dff9a0f0ef650dcce9c635bb2f877ab37be5"><code>cbd2dff</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/build-push-action/commit/f76f51f12900bb84aa9d1a498f35870ef1f76675"><code>f76f51f</code></a>
chore(deps): Bump <code>@​docker/actions-toolkit</code> from 0.78.0 to
0.79.0</li>
<li><a
href="https://github.com/docker/build-push-action/commit/7d03e66b5f24d6b390ab64b132795fd3ef4152c8"><code>7d03e66</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1473">#1473</a>
from crazy-max/rm-deprecated-envs</li>
<li><a
href="https://github.com/docker/build-push-action/commit/98f853d923dd281a3bcbbb98a0712a91aa913322"><code>98f853d</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/build-push-action/commit/cadccf6e8c7385c86d9cb0800cf07672645cc238"><code>cadccf6</code></a>
remove deprecated envs</li>
<li><a
href="https://github.com/docker/build-push-action/commit/03fe8775e325e34fffbda44c73316f8287aea372"><code>03fe877</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1478">#1478</a>
from docker/dependabot/github_actions/docker/setup-b...</li>
<li><a
href="https://github.com/docker/build-push-action/commit/827e36650e1fa7386d09422b5ba3c068fdbe0a1d"><code>827e366</code></a>
chore(deps): Bump docker/setup-buildx-action from 3 to 4</li>
<li><a
href="https://github.com/docker/build-push-action/commit/e25db879d025485a4eebd64fea9bb88a43632da6"><code>e25db87</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1474">#1474</a>
from crazy-max/rm-export-build-tool</li>
<li><a
href="https://github.com/docker/build-push-action/commit/1ac2573b5c8b4e4621d5453ab2a99e83725242bd"><code>1ac2573</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1470">#1470</a>
from crazy-max/node24</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/build-push-action/compare/v6...v7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=docker/build-push-action&package-manager=github_actions&previous-version=6&new-version=7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 11:14:03 +01:00
Tobias Gesellchen 17bd3ea9ed Fix: encode IP addresses as raw octets in Subject Alternative Names (#116)
- Update `GenerateCertificate` to correctly identify IP addresses and
add them to `IPAddresses` instead of `DNSNames`.
- Update `GetServerTLSConfig` to verify both `DNSNames` and
`IPAddresses` when checking certificate validity.
- Add `TestCertificateManagerIPAddress` to `certmanager_test.go` to
ensure correct encoding and prevent regressions.
- Ensure compliance with RFC 5280 by using binary encoding for IP
addresses in certificates.
2026-03-17 09:33:51 +01:00
Tobias Gesellchen ad5344b309 Do not use /bmx for our custom endpoint (#115)
Follow-up for https://github.com/gesellix/Bose-SoundTouch/pull/114
2026-03-16 23:36:36 +01:00
Tobias Gesellchen 8d95e170f6 Add a custom-radio url stream source (#114)
Based on the descriptions at

- https://gist.github.com/rody64/98a59990ff60ea962cac72cbe93edf56
-
https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/discussions/37

Example usage:

```
go run ./cmd/soundtouch-cli --host 192.... source custom-radio --url https://stream.antenne.de/chillout/stream/aacp --service-url http://soundtouch.local:8000
Selecting custom radio stream from 192....:8090...
  URL: https://stream.antenne.de/chillout/stream/aacp
  Proxy: http://soundtouch.local:8000/bmx/custom/v1/playback/aHR0cHM6Ly9zdHJlYW0uYW50ZW5uZS5kZS9jaGlsbG91dC9zdHJlYW0vYWFjcA==
✓ Custom radio stream selected
```

Relates to https://github.com/gesellix/Bose-SoundTouch/issues/94
2026-03-16 23:17:50 +01:00
dependabot[bot]andlnx01 565ca33345 deps(deps): bump the golang group with 4 updates (#113)
Bumps the golang group with 4 updates:
[golang.org/x/crypto](https://github.com/golang/crypto),
[golang.org/x/mod](https://github.com/golang/mod),
[golang.org/x/net](https://github.com/golang/net) and
[golang.org/x/tools](https://github.com/golang/tools).

Updates `golang.org/x/crypto` from 0.48.0 to 0.49.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/crypto/commit/982eaa62dfb7273603b97fc1835561450096f3bd"><code>982eaa6</code></a>
go.mod: update golang.org/x dependencies</li>
<li><a
href="https://github.com/golang/crypto/commit/159944f128e9b3fdeb5a5b9b102a961904601a87"><code>159944f</code></a>
ssh,acme: clean up tautological/impossible nil conditions</li>
<li><a
href="https://github.com/golang/crypto/commit/a408498e55412f2ae2a058336f78889fb1ba6115"><code>a408498</code></a>
acme: only require prompt if server has terms of service</li>
<li><a
href="https://github.com/golang/crypto/commit/cab0f718548e8a858701b7b48161f44748532f58"><code>cab0f71</code></a>
all: upgrade go directive to at least 1.25.0 [generated]</li>
<li><a
href="https://github.com/golang/crypto/commit/2f26647a795e74e712b3aebc2655bca60b2686f9"><code>2f26647</code></a>
x509roots/fallback: update bundle</li>
<li>See full diff in <a
href="https://github.com/golang/crypto/compare/v0.48.0...v0.49.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/mod` from 0.33.0 to 0.34.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/mod/commit/1ac721dff8591283e59aba6412a0eafc8b950d83"><code>1ac721d</code></a>
go.mod: update golang.org/x dependencies</li>
<li><a
href="https://github.com/golang/mod/commit/fb1fac8b369ec75b114cb416119e80d3aebda7f5"><code>fb1fac8</code></a>
all: upgrade go directive to at least 1.25.0 [generated]</li>
<li>See full diff in <a
href="https://github.com/golang/mod/compare/v0.33.0...v0.34.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/net` from 0.51.0 to 0.52.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/net/commit/316e20ce34d380337f7983808c26948232e16455"><code>316e20c</code></a>
go.mod: update golang.org/x dependencies</li>
<li><a
href="https://github.com/golang/net/commit/9767a42264fa70b674c643d0c87ee95c309a4553"><code>9767a42</code></a>
internal/http3: add support for plugging into net/http</li>
<li><a
href="https://github.com/golang/net/commit/4a812844d820f49985ee15998af285c43b0a6b96"><code>4a81284</code></a>
http2: update docs to disrecommend this package</li>
<li><a
href="https://github.com/golang/net/commit/dec6603c16144712aab7f44821471346b35a2230"><code>dec6603</code></a>
dns/dnsmessage: reject too large of names early during unpack</li>
<li><a
href="https://github.com/golang/net/commit/8afa12f927391ba32da2b75b864a3ad04cac6376"><code>8afa12f</code></a>
http2: deprecate write schedulers</li>
<li><a
href="https://github.com/golang/net/commit/38019a2dbc2645a4c06a1e983681eefb041171c8"><code>38019a2</code></a>
http2: add missing copyright header to export_test.go</li>
<li><a
href="https://github.com/golang/net/commit/039b87fac41ca283465e12a3bcc170ccd6c92f84"><code>039b87f</code></a>
internal/http3: return error when Write is used after status 304 is
set</li>
<li><a
href="https://github.com/golang/net/commit/6267c6c4c825a78e4c9cbdc19c705bc81716597c"><code>6267c6c</code></a>
internal/http3: add HTTP 103 Early Hints support to ClientConn</li>
<li><a
href="https://github.com/golang/net/commit/591bdf35bce56ad50f53555c3cbb31e4bdda2d58"><code>591bdf3</code></a>
internal/http3: add HTTP 103 Early Hints support to Server</li>
<li><a
href="https://github.com/golang/net/commit/1faa6d8722697d9a1d8d4e973b3c46c7a5563f6c"><code>1faa6d8</code></a>
internal/http3: avoid potential race when aborting RoundTrip</li>
<li>Additional commits viewable in <a
href="https://github.com/golang/net/compare/v0.51.0...v0.52.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/tools` from 0.42.0 to 0.43.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/tools/commit/24a8e95f9d7ae2696f66314da5e50c0d98ccaa90"><code>24a8e95</code></a>
go.mod: update golang.org/x dependencies</li>
<li><a
href="https://github.com/golang/tools/commit/3dd57fba1a6eed320cd9ea2b292cacdacda1e5e8"><code>3dd57fb</code></a>
gopls/internal/mcp: refactor unified diff generation</li>
<li><a
href="https://github.com/golang/tools/commit/fcc014db2b644cc1e0a9d08157efab0156699ada"><code>fcc014d</code></a>
cmd/digraph: fix package doc</li>
<li><a
href="https://github.com/golang/tools/commit/39f0f5c6d34afcb5664463f6e97c076187a305ea"><code>39f0f5c</code></a>
cmd/stress: add -failfast flag</li>
<li><a
href="https://github.com/golang/tools/commit/063c2644e296d3154b4dcbfc15ebeb09e6f07290"><code>063c264</code></a>
gopls/test/integration/misc: add diagnostics to flaky test</li>
<li><a
href="https://github.com/golang/tools/commit/deb6130cda665525d826291d591e988ace74f447"><code>deb6130</code></a>
gopls/internal/golang: fix hover panic in raw strings with CRLF</li>
<li><a
href="https://github.com/golang/tools/commit/5f1186b97512a314f8a35509072d7657eaf7c60a"><code>5f1186b</code></a>
gopls/internal/analysis/driverutil: remove unnecessary new imports</li>
<li><a
href="https://github.com/golang/tools/commit/ff454944261ad40f98abfc097fae89272ce40935"><code>ff45494</code></a>
go/analysis: expose GoMod etc. to Pass.Module</li>
<li><a
href="https://github.com/golang/tools/commit/62daff4834809b6cce693f6f0dff1c2722cb6328"><code>62daff4</code></a>
go/analysis/passes/inline: fix panic in inlineAlias with instantiated
generic...</li>
<li><a
href="https://github.com/golang/tools/commit/fcb6088b9059538dd6bcbd5238c10ffdc71700b5"><code>fcb6088</code></a>
x/tools: delete obsolete code</li>
<li>Additional commits viewable in <a
href="https://github.com/golang/tools/compare/v0.42.0...v0.43.0">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 13:36:34 +01:00
Tobias GesellchenandJunie e2d52d9e3b refactor(marge): improve XML parity for account and recent services (#112)
- XML Refactoring: Transitioned from manual string concatenation to
structured XML marshaling using specialized Go models to match upstream
API responses exactly.
- Service Enhancements: Implemented robust device discovery via power_on
handling, improved source metadata persistence, and standardized ID
generation logic.
- Parity & Consistency: Fixed data loss and formatting mismatches for
lastplayedat, serialNumber, and nested <source> elements.
- Infrastructure & Testing: Added a comprehensive suite of regression
and parity reproduction tests, centralized common XML constants, and
documented progress.

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-15 13:31:43 +01:00
dependabot[bot] f3b74998f1 ci(deps): bump docker/metadata-action from 5 to 6
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-10 12:04:13 +01:00
dependabot[bot] ce15e706b8 ci(deps): bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-10 12:04:00 +01:00
dependabot[bot] bc61081acc ci(deps): bump docker/setup-buildx-action in the setup-actions group
Bumps the setup-actions group with 1 update: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action).


Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: setup-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-10 12:03:47 +01:00
dependabot[bot] 16f7327b7a deps(deps): bump the golang group with 2 updates
Bumps the golang group with 2 updates: [golang.org/x/sync](https://github.com/golang/sync) and [golang.org/x/sys](https://github.com/golang/sys).


Updates `golang.org/x/sync` from 0.19.0 to 0.20.0
- [Commits](https://github.com/golang/sync/compare/v0.19.0...v0.20.0)

Updates `golang.org/x/sys` from 0.41.0 to 0.42.0
- [Commits](https://github.com/golang/sys/compare/v0.41.0...v0.42.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-version: 0.20.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/sys
  dependency-version: 0.42.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 20:46:04 +01:00
129 changed files with 9641 additions and 4654 deletions
+11 -5
View File
@@ -43,8 +43,14 @@ jobs:
- name: Run tests
run: go test -v -race -coverprofile=coverage.out ./...
- name: Build service
run: make build-service
- name: Run HTTP client integration tests
run: make test-http-client
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
file: ./coverage.out
flags: unittests
@@ -256,11 +262,11 @@ jobs:
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -268,7 +274,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
@@ -276,7 +282,7 @@ jobs:
type=ref,event=pr
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
+1 -1
View File
@@ -34,4 +34,4 @@ jobs:
path: '_site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+4 -4
View File
@@ -492,10 +492,10 @@ jobs:
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -503,7 +503,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
@@ -512,7 +512,7 @@ jobs:
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
+40 -2
View File
@@ -20,6 +20,8 @@ EXAMPLE_UPNP_NAME=example-upnp
EXAMPLE_UPNP_PATH=./cmd/$(EXAMPLE_UPNP_NAME)
SCANNER_NAME=mdns-scanner
SCANNER_PATH=./cmd/$(SCANNER_NAME)
FAVICON_GEN_NAME=favicon-gen
FAVICON_GEN_PATH=./cmd/$(FAVICON_GEN_NAME)
BUILD_DIR=./build
# Version info
@@ -27,7 +29,7 @@ BUILD_DIR=./build
all: check build
build: build-cli build-service build-examples
build: build-cli build-service build-examples build-favicon-gen
build-cli:
@echo "Building $(BINARY_NAME)..."
@@ -48,6 +50,11 @@ build-examples:
@echo "Building $(SCANNER_NAME)..."
$(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
build-favicon-gen:
@echo "Building $(FAVICON_GEN_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
build-all: build-linux build-darwin build-windows build-examples-all
build-linux:
@@ -96,7 +103,37 @@ test-coverage:
$(GOCMD) tool cover -html=coverage.out -o coverage.html
@echo "Coverage report generated: coverage.html"
check: fmt vet test
check: fmt vet test test-http-client
test-http-client:
@echo "Running HTTP client integration tests..."
@docker network create soundtouch-test-net || true
@docker build -t soundtouch-service-test .
@docker run -d --name soundtouch-service --network soundtouch-test-net \
-e PORT=8000 \
soundtouch-service-test
@echo "Waiting for service to start..."
@sleep 5
@docker run --rm --network soundtouch-test-net \
-v $(PWD)/tests/integration/http-client:/workdir \
jetbrains/intellij-http-client:2026.1 \
--env-file /workdir/http-client.env.json \
--env ci \
/workdir/create_account.http \
/workdir/register_device.http \
/workdir/power_on.http \
/workdir/get_provider_settings.http \
/workdir/get_full_account.http \
/workdir/get_group.http \
/workdir/unregister_device.http \
--report; \
EXIT_CODE=$$?; \
docker logs soundtouch-service; \
docker stop soundtouch-service; \
docker rm soundtouch-service; \
docker rmi soundtouch-service-test; \
docker network rm soundtouch-test-net; \
exit $$EXIT_CODE
fmt:
@echo "Formatting code..."
@@ -226,6 +263,7 @@ help:
@echo " build - Build the CLI tool, service, and examples"
@echo " build-cli - Build only the CLI tool"
@echo " build-service - Build only the service"
@echo " build-favicon-gen - Build the favicon generator"
@echo " build-examples - Build only the example programs"
@echo " build-all - Build for all platforms"
@echo " test - Run tests"
+30 -30
View File
@@ -17,6 +17,7 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
-**Real-time Events**: WebSocket connection for live device state monitoring
- 🔍 **Device Discovery**: Automatic discovery via UPnP/SSDP and mDNS
- 📻 **Content Navigation**: Browse and search TuneIn, Pandora, Spotify, local music
- 📻 **Custom Radio**: Play any stream URL via [flexible proxying](docs/guides/CLI-REFERENCE.md#custom-radio-selection-via-soundtouch-service)
- 📻 **RadioBrowser**: Access thousands of internet radio stations via [radio-browser.info](docs/reference/radio-browser.md)
- 🎙️ **Station Management**: Add and play radio stations without presets
- 🖥️ **CLI Tool**: Comprehensive command-line interface
@@ -78,7 +79,6 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
- **🔧 Device Migration**: Seamlessly transition devices to local control
- **🌐 Web Management UI**: Easy browser-based setup and management
- **🎮 Stockholm Mini**: A minimal reverse-engineered UI for device control (accessible at `/web/stockholm-mini/`)
- **💾 Persistent Data**: Store presets, recents, and sources locally
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
@@ -107,7 +107,7 @@ package main
import (
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
)
@@ -117,20 +117,20 @@ func main() {
Host: "192.168.1.100",
Port: 8090,
})
// Get device information
info, err := c.GetDeviceInfo()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Device: %s\n", info.Name)
// Control playback
err = c.Play()
if err != nil {
log.Fatal(err)
}
// Set volume
err = c.SetVolume(50)
if err != nil {
@@ -148,7 +148,7 @@ import (
"fmt"
"log"
"time"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
)
@@ -159,9 +159,9 @@ func main() {
if err != nil {
log.Fatal(err)
}
for _, device := range devices {
fmt.Printf("Found: %s at %s:%d\n",
fmt.Printf("Found: %s at %s:%d\n",
device.Name, device.Host, device.Port)
}
}
@@ -175,7 +175,7 @@ import (
"context"
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
@@ -185,13 +185,13 @@ func main() {
Host: "192.168.1.100",
Port: 8090,
})
// Subscribe to device events
events, err := c.SubscribeToEvents(context.Background())
if err != nil {
log.Fatal(err)
}
for event := range events {
switch e := event.(type) {
case *models.NowPlayingUpdated:
@@ -212,7 +212,7 @@ package main
import (
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
@@ -222,21 +222,21 @@ func main() {
Host: "192.168.1.100",
Port: 8090,
})
// Get current presets
presets, err := c.GetPresets()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found %d presets\n", len(presets.Preset))
// Store currently playing content as preset 1
err = c.StoreCurrentAsPreset(1)
if err != nil {
log.Fatal(err)
}
// Store Spotify playlist as preset 2
spotifyContent := &models.ContentItem{
Source: "SPOTIFY",
@@ -250,7 +250,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
// Store radio station as preset 3
radioContent := &models.ContentItem{
Source: "TUNEIN",
@@ -263,13 +263,13 @@ func main() {
if err != nil {
log.Fatal(err)
}
// Select preset 1
err = c.SelectPreset(1)
if err != nil {
log.Fatal(err)
}
fmt.Println("Preset management complete!")
}
```
@@ -280,7 +280,7 @@ package main
import (
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
@@ -290,7 +290,7 @@ func main() {
Host: "192.168.1.100", // Master speaker
Port: 8090,
})
// Create a multiroom zone
zone := &models.Zone{
Master: "192.168.1.100",
@@ -299,12 +299,12 @@ func main() {
{IPAddress: "192.168.1.102"}, // Kitchen
},
}
err := master.SetZone(zone)
if err != nil {
log.Fatal(err)
}
fmt.Println("Multiroom zone created!")
}
```
@@ -315,7 +315,7 @@ package main
import (
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
)
@@ -324,13 +324,13 @@ func main() {
Host: "192.168.1.100",
Port: 8090,
})
// Play Text-to-Speech message (language code "EN", "DE", etc.)
err := c.PlayTTS("Welcome home!", "your-app-key", "EN", 70)
if err != nil {
log.Fatal(err)
}
// Play audio content from URL
err = c.PlayURL(
"https://example.com/doorbell.mp3",
@@ -343,13 +343,13 @@ func main() {
if err != nil {
log.Fatal(err)
}
// Play notification beep
err = c.PlayNotificationBeep()
if err != nil {
log.Fatal(err)
}
fmt.Println("Notifications sent!")
}
```
@@ -359,7 +359,7 @@ func main() {
This library supports all Bose SoundTouch-compatible devices, including:
- SoundTouch 10, 20, 30 series
- SoundTouch Portable
- SoundTouch Portable
- Wave SoundTouch music system
- SoundTouch-enabled Bose speakers
@@ -520,7 +520,7 @@ This project builds upon the excellent work of several community projects:
These projects together form a comprehensive ecosystem for SoundTouch device management:
- **This Project**: Go library + CLI + service for programmatic control and offline operation
- **SoundCork**: Python-based service interception and cloud replacement
- **SoundCork**: Python-based service interception and cloud replacement
- **SoundTouch Plus**: Home Assistant integration with extensive device support
- **ÜberBöse**: API research and advanced endpoint discovery
- **SoundTouch Hook**: Advanced reverse engineering and process instrumentation
+152
View File
@@ -0,0 +1,152 @@
// Package main provides a utility to generate PNG and ICO favicons from SVG source files.
package main
import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
"image"
"image/png"
"log"
"os"
"path/filepath"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
)
func main() {
mediaDir := "pkg/service/handlers/web/img"
files := []string{"favicon-braille", "favicon-morse"}
for _, name := range files {
svgPath := filepath.Join(mediaDir, name+".svg")
pngPath := filepath.Join(mediaDir, name+".png")
icoPath := filepath.Join(mediaDir, name+".ico")
fmt.Printf("Processing %s...\n", name)
// 1. Render SVG to PNG
img, err := renderSVG(svgPath, 32, 32)
if err != nil {
log.Fatalf("Failed to render %s: %v", svgPath, err)
}
f, err := os.Create(pngPath)
if err != nil {
log.Fatalf("Failed to create %s: %v", pngPath, err)
}
if err := png.Encode(f, img); err != nil {
f.Close()
log.Fatalf("Failed to encode PNG %s: %v", pngPath, err)
}
f.Close()
fmt.Printf("Created %s\n", pngPath)
// 2. Create ICO (containing multiple sizes)
sizes := []int{16, 32, 48}
var images []image.Image
for _, s := range sizes {
m, err := renderSVG(svgPath, s, s)
if err != nil {
log.Fatalf("Failed to render %s at size %d: %v", svgPath, s, err)
}
images = append(images, m)
}
if err := writeICO(icoPath, images); err != nil {
log.Fatalf("Failed to write ICO %s: %v", icoPath, err)
}
fmt.Printf("Created %s\n", icoPath)
}
}
func renderSVG(path string, w, h int) (image.Image, error) {
in, err := os.Open(path)
if err != nil {
return nil, err
}
defer in.Close()
icon, err := oksvg.ReadIconStream(in)
if err != nil {
return nil, err
}
icon.SetTarget(0, 0, float64(w), float64(h))
rgba := image.NewRGBA(image.Rect(0, 0, w, h))
gv := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())
dasher := rasterx.NewDasher(w, h, gv)
icon.Draw(dasher, 1.0)
return rgba, nil
}
// Simple ICO encoder that wraps PNGs
func writeICO(path string, images []image.Image) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
bw := bufio.NewWriter(f)
defer bw.Flush()
// ICONDIR header
// Reserved (2), Type (2), Count (2)
binary.Write(bw, binary.LittleEndian, uint16(0))
binary.Write(bw, binary.LittleEndian, uint16(1)) // 1 = ICO
binary.Write(bw, binary.LittleEndian, uint16(len(images)))
var pngData [][]byte
for _, img := range images {
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return err
}
pngData = append(pngData, buf.Bytes())
}
offset := uint32(6 + len(images)*16)
for i, img := range images {
b := img.Bounds()
width := uint8(b.Dx())
if b.Dx() >= 256 {
width = 0
}
height := uint8(b.Dy())
if b.Dy() >= 256 {
height = 0
}
// ICONDIRENTRY
bw.WriteByte(width)
bw.WriteByte(height)
bw.WriteByte(0) // Color count
bw.WriteByte(0) // Reserved
binary.Write(bw, binary.LittleEndian, uint16(1)) // Planes (1)
binary.Write(bw, binary.LittleEndian, uint16(32)) // Bits per pixel (32)
binary.Write(bw, binary.LittleEndian, uint32(len(pngData[i])))
binary.Write(bw, binary.LittleEndian, offset)
offset += uint32(len(pngData[i]))
}
for _, data := range pngData {
bw.Write(data)
}
return nil
}
+67
View File
@@ -652,6 +652,73 @@ func listMusicServiceAccounts(c *cli.Context) error {
return nil
}
// pairDevice triggers the Stockholm registration flow via WebSocket
func pairDevice(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
accountID := c.String("id")
token := c.String("token")
PrintDeviceHeader("Pairing device with Marge account", clientConfig.Host, clientConfig.Port)
fmt.Printf(" Account ID: %s\n", accountID)
// We need a WebSocket client for this
ws := client.NewWebSocketClient(nil)
err = ws.Connect()
if err != nil {
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
}
defer func() { _ = ws.Disconnect() }()
err = ws.PairWithAccount(accountID, token)
if err != nil {
return fmt.Errorf("failed to send pairing request: %w", err)
}
PrintSuccess("Pairing request sent successfully")
fmt.Println("💡 The device will now register itself with the cloud service.")
return nil
}
// unpairDevice triggers the Stockholm unregistration flow via WebSocket
func unpairDevice(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
PrintDeviceHeader("Unpairing device from Marge account", clientConfig.Host, clientConfig.Port)
// We need a WebSocket client for this
ws := client.NewWebSocketClient(nil)
err = ws.Connect()
if err != nil {
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
}
defer func() { _ = ws.Disconnect() }()
err = ws.UnPairFromAccount()
if err != nil {
return fmt.Errorf("failed to send unpairing request: %w", err)
}
PrintSuccess("Unpairing request sent successfully")
return nil
}
// getServiceDisplayName returns a user-friendly display name for a service
func getServiceDisplayName(source string) string {
switch source {
+57
View File
@@ -1,7 +1,9 @@
package main
import (
"encoding/base64"
"fmt"
"net/url"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -251,6 +253,61 @@ func selectLocalInternetRadio(c *cli.Context) error {
return nil
}
// selectCustomRadio handles selecting custom radio stream via soundtouch-service
func selectCustomRadio(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
streamURL := c.String("url")
itemName := c.String("name")
containerArt := c.String("artwork")
serviceURL := c.String("service-url")
encodedURL := base64.URLEncoding.EncodeToString([]byte(streamURL))
location := fmt.Sprintf("%s/custom/v1/playback/%s", serviceURL, encodedURL)
params := url.Values{}
if itemName != "" {
params.Add("name", itemName)
}
if containerArt != "" {
params.Add("imageUrl", containerArt)
}
if len(params) > 0 {
location += "?" + params.Encode()
}
// Check LOCAL_INTERNET_RADIO availability
checker := NewServiceAvailabilityChecker(client)
if !checker.CheckSourceAvailable("LOCAL_INTERNET_RADIO", "select custom radio") {
return fmt.Errorf("LOCAL_INTERNET_RADIO is not available")
}
PrintDeviceHeader("Selecting custom radio stream", clientConfig.Host, clientConfig.Port)
if itemName != "" {
fmt.Printf(" Station: %s\n", itemName)
}
fmt.Printf(" URL: %s\n", streamURL)
fmt.Printf(" Proxy: %s\n", location)
err = client.SelectLocalInternetRadio(location, "", itemName, containerArt)
if err != nil {
return fmt.Errorf("failed to select custom radio: %w", err)
}
PrintSuccess("Custom radio stream selected")
return nil
}
// selectLocalMusic handles selecting LOCAL_MUSIC source
func selectLocalMusic(c *cli.Context) error {
clientConfig := GetClientConfig(c)
+2 -2
View File
@@ -196,7 +196,7 @@ var httpClient = &http.Client{
}
func fetchTuneInMetadata(url string) (*Metadata, error) {
if !strings.Contains(url, "tunein.com/radio/") {
if !strings.Contains(url, "tunein.com/radio/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
return nil, fmt.Errorf("url is not a TuneIn radio URL")
}
@@ -256,7 +256,7 @@ func fetchTuneInMetadata(url string) (*Metadata, error) {
}
func fetchSpotifyMetadata(url string) (*Metadata, error) {
if !strings.Contains(url, "open.spotify.com/") {
if !strings.Contains(url, "open.spotify.com/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
return nil, fmt.Errorf("url is not a Spotify URL")
}
+4 -4
View File
@@ -7,7 +7,7 @@ import (
)
func TestFetchTuneInMetadata(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
html := `
<!doctype html>
<html>
@@ -30,7 +30,7 @@ func TestFetchTuneInMetadata(t *testing.T) {
defer func() { httpClient = oldClient }()
metadata, err := fetchTuneInMetadata("https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/")
metadata, err := fetchTuneInMetadata(ts.URL + "/radio/WDR-2-Rheinland-1004-s213886/")
if err != nil {
t.Fatalf("fetchTuneInMetadata() error = %v", err)
}
@@ -162,7 +162,7 @@ func TestResolveLocationSpotify(t *testing.T) {
}
func TestFetchSpotifyMetadata(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
html := `
<!doctype html>
<html>
@@ -185,7 +185,7 @@ func TestFetchSpotifyMetadata(t *testing.T) {
defer func() { httpClient = oldClient }()
metadata, err := fetchSpotifyMetadata("https://open.spotify.com/album/7F50uh7oGitmAEScRKV6pD")
metadata, err := fetchSpotifyMetadata(ts.URL + "/album/7F50uh7oGitmAEScRKV6pD")
if err != nil {
t.Fatalf("fetchSpotifyMetadata() error = %v", err)
}
+52
View File
@@ -924,6 +924,34 @@ func main() {
},
},
},
{
Name: "custom-radio",
Usage: "Select custom radio stream via soundtouch-service",
Action: selectCustomRadio,
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "url",
Aliases: []string{"u"},
Usage: "Stream URL",
Required: true,
},
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Station name",
},
&cli.StringFlag{
Name: "artwork",
Usage: "Station artwork URL",
},
&cli.StringFlag{
Name: "service-url",
Usage: "URL of the soundtouch-service (default: http://localhost:8080)",
Value: "http://localhost:8080",
},
},
},
{
Name: "local-music",
Usage: "Select local music content (LOCAL_MUSIC)",
@@ -2010,6 +2038,30 @@ func main() {
},
},
},
{
Name: "pair",
Usage: "Pair the device with a Marge cloud account (Stockholm registration)",
Action: pairDevice,
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "id",
Usage: "Marge account ID (e.g., 1234567)",
Required: true,
},
&cli.StringFlag{
Name: "token",
Usage: "User authorization token",
Required: true,
},
},
},
{
Name: "unpair",
Usage: "Unpair the device from its Marge cloud account",
Action: unpairDevice,
Before: RequireHost,
},
},
},
// Token commands
+54 -61
View File
@@ -189,6 +189,11 @@ func main() {
Usage: "Endpoints to mirror to Bose Cloud (comma-separated or multiple flags)",
EnvVars: []string{"MIRROR_ENDPOINTS"},
},
&cli.StringSliceFlag{
Name: "skip-mirror-endpoints",
Usage: "Endpoints to skip mirroring to Bose Cloud (comma-separated or multiple flags)",
EnvVars: []string{"SKIP_MIRROR_ENDPOINTS"},
},
&cli.StringSliceFlag{
Name: "internal-paths",
Usage: "Paths for internal requests (comma-separated or multiple flags)",
@@ -235,13 +240,13 @@ func main() {
sm := setup.NewManager(config.serverURL, ds, cm)
sm.MgmtUsername = config.mgmtUsername
sm.MgmtPassword = config.mgmtPassword
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.migrationEnabled, config.migrationDryRun)
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
sm.GetDNSRunning = server.GetDNSRunning
server.SetHTTPServerURL(config.httpsServerURL)
server.SetVersionInfo(version, commit, date)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.PreferredSource)
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource)
server.SetInternalPaths(persisted.InternalPaths)
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
@@ -374,6 +379,7 @@ type serviceConfig struct {
dnsBind string
mirrorEnabled bool
mirrorEndpoints []string
skipMirrorEndpoints []string
internalPaths []string
discoveryInterval time.Duration
domains []string
@@ -448,6 +454,7 @@ func loadConfig(c *cli.Context) serviceConfig {
mgmtPassword := c.String("mgmt-password")
mirrorEnabled := c.Bool("mirror-enabled")
mirrorEndpoints := c.StringSlice("mirror-endpoints")
skipMirrorEndpoints := c.StringSlice("skip-mirror-endpoints")
internalPaths := c.StringSlice("internal-paths")
migrationEnabled := c.Bool("migration-enabled")
migrationDryRun := c.Bool("migration-dry-run")
@@ -469,6 +476,7 @@ func loadConfig(c *cli.Context) serviceConfig {
dnsBind: dnsBind,
mirrorEnabled: mirrorEnabled,
mirrorEndpoints: mirrorEndpoints,
skipMirrorEndpoints: skipMirrorEndpoints,
internalPaths: internalPaths,
discoveryInterval: discoveryInterval,
domains: domains,
@@ -565,6 +573,7 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
config.mirrorEnabled = persisted.MirrorEnabled
config.mirrorEndpoints = persisted.MirrorEndpoints
config.skipMirrorEndpoints = persisted.SkipMirrorEndpoints
config.preferredSource = persisted.PreferredSource
config.internalPaths = persisted.InternalPaths
@@ -573,20 +582,21 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
settings := datastore.Settings{
ServerURL: config.serverURL,
HTTPServerURL: config.httpsServerURL,
RedactLogs: config.redact,
LogBodies: config.logBody,
RecordInteractions: config.record,
DiscoveryInterval: config.discoveryInterval.String(),
DiscoveryEnabled: true,
DNSEnabled: config.dnsEnabled,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
MirrorEnabled: config.mirrorEnabled,
MirrorEndpoints: config.mirrorEndpoints,
PreferredSource: config.preferredSource,
InternalPaths: config.internalPaths,
ServerURL: config.serverURL,
HTTPServerURL: config.httpsServerURL,
RedactLogs: config.redact,
LogBodies: config.logBody,
RecordInteractions: config.record,
DiscoveryInterval: config.discoveryInterval.String(),
DiscoveryEnabled: true,
DNSEnabled: config.dnsEnabled,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
MirrorEnabled: config.mirrorEnabled,
MirrorEndpoints: config.mirrorEndpoints,
SkipMirrorEndpoints: config.skipMirrorEndpoints,
PreferredSource: config.preferredSource,
InternalPaths: config.internalPaths,
Shortcuts: map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
@@ -663,9 +673,12 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
streamingRoutes := func(r chi.Router) {
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
r.Post("/account", server.HandleMargeCreateAccount)
r.Post("/account/login", server.HandleMargeLogin)
r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
@@ -723,6 +736,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
})
r.Route("/oauth", func(r chi.Router) {
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token", server.HandleBoseLegacyToken)
r.HandleFunc("/*", server.HandleBoseProxy)
})
@@ -740,45 +755,34 @@ func setupRouter(server *handlers.Server) *chi.Mux {
// All other management endpoints require Basic Auth.
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
r.Get("/accounts/{accountId}/speakers", server.HandleMgmtListSpeakers)
r.Route("/accounts", func(r chi.Router) {
r.Get("/", server.HandleMgmtListAccounts)
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
})
r.Route("/spotify", func(r chi.Router) {
r.Post("/init", server.HandleMgmtSpotifyInit)
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/token", server.HandleMgmtSpotifyToken)
r.Post("/entity", server.HandleMgmtSpotifyEntity)
r.Post("/prime", server.HandleMgmtPrimeDevice)
})
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
r.Post("/spotify/init", server.HandleMgmtSpotifyInit)
r.Post("/spotify/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/spotify/token", server.HandleMgmtSpotifyToken)
r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity)
r.Post("/spotify/prime", server.HandleMgmtPrimeDevice)
})
})
r.Get("/proxy/*", server.HandleProxyRequest)
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Get("/devices/{deviceId}/info", server.HandleGetStockholmDeviceInfo)
r.Post("/devices/{deviceId}/key/{key}", server.HandleDeviceKey)
r.Post("/devices/{deviceId}/volume/{level}", server.HandleDeviceVolume)
// Stockholm Mini app
r.Handle("/stockholm-mini/*", http.StripPrefix("/stockholm-mini/", http.FileServer(http.Dir("pkg/service/handlers/web/stockholm-mini"))))
r.Route("/devices", func(r chi.Router) {
r.Get("/", server.HandleListDiscoveredDevices)
r.Post("/", server.HandleAddManualDevice)
r.Route("/{deviceId}", func(r chi.Router) {
r.Delete("/", server.HandleRemoveDevice)
r.Get("/events", server.HandleGetDeviceEvents)
r.Get("/info", server.HandleGetDeviceInfo)
r.Get("/ws", server.HandleDeviceWebSocket)
r.Post("/key/{key}", server.HandleDeviceKey)
r.Post("/volume/{level}", server.HandleDeviceVolume)
r.Post("/reboot", server.HandleRebootDevice)
})
})
r.Get("/version", server.HandleGetVersionInfo)
r.Route("/setup", func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Post("/devices", server.HandleAddManualDevice)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
r.Post("/discover", server.HandleTriggerDiscovery)
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
@@ -799,6 +803,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Get("/version", server.HandleGetVersionInfo)
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
@@ -812,19 +817,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Route("/devices/{deviceId}", func(r chi.Router) {
r.Get("/summary", server.HandleGetMigrationSummary)
r.Post("/migrate", server.HandleMigrateDevice)
r.Post("/revert", server.HandleRevertMigration)
r.Post("/trust-ca", server.HandleTrustCACert)
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
r.Post("/backup", server.HandleBackupConfig)
r.Post("/sync", server.HandleInitialSync)
r.Post("/test-connection", server.HandleTestConnection)
r.Post("/test-hosts", server.HandleTestHostsRedirection)
r.Post("/test-dns", server.HandleTestDNSRedirection)
})
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
})
r.NotFound(server.HandleNotFound)
+20 -3
View File
@@ -23,6 +23,14 @@ All content selection features from the [SoundTouch WebServices API Wiki](https:
- Automatic defaults for missing parameters
- **Use Cases**: Internet radio streams, proxy-based radio services
#### `SelectLocalInternetRadio(location, ...)` via `soundtouch-service`
- **Purpose**: Select custom radio stream via local `soundtouch-service` proxy
- **Features**:
- Flexible stream URL encoding (Base64 or URL-escaped)
- Dynamic generation of Bose-compatible playback JSON
- Seamless integration with existing `LOCAL_INTERNET_RADIO` source
- **Use Case**: Playing any internet radio URL without external proxy dependencies
#### `SelectLocalMusic(location, sourceAccount, itemName, containerArt string) error`
- **Purpose**: Select LOCAL_MUSIC content from SoundTouch App Media Server
- **Requirements**: SoundTouch App Media Server running on a computer
@@ -47,6 +55,15 @@ soundtouch-cli --host <device> source internet-radio \
--artwork "https://example.com/art.png"
```
#### `soundtouch-cli source custom-radio`
```bash
soundtouch-cli --host <device> source custom-radio \
--url "https://stream.example.com/radio" \
--name "My Station" \
--artwork "https://example.com/art.png" \
--service-url "http://localhost:8080"
```
#### `soundtouch-cli source local-music`
```bash
soundtouch-cli --host <device> source local-music \
@@ -101,7 +118,7 @@ Comprehensive test suites implemented for all new functionality:
### Example Code
Complete working example demonstrating:
- LOCAL_INTERNET_RADIO with streamUrl proxy format
- LOCAL_INTERNET_RADIO with direct streams
- LOCAL_INTERNET_RADIO with direct streams
- LOCAL_MUSIC content selection
- STORED_MUSIC content selection
- Generic ContentItem usage
@@ -152,7 +169,7 @@ All convenience methods create properly structured `ContentItem` objects:
Based on the wiki structure, these related features are also supported:
1. **LOCAL_MUSIC**: ✅ Fully implemented
2. **STORED_MUSIC**: ✅ Fully implemented
2. **STORED_MUSIC**: ✅ Fully implemented
3. **SPOTIFY**: ✅ Previously implemented
4. **TUNEIN**: ✅ Previously implemented
5. **BLUETOOTH**: ✅ Previously implemented
@@ -169,7 +186,7 @@ err := client.SelectLocalInternetRadio(location, "", "My Station", "")
// Direct ContentItem
contentItem := &models.ContentItem{
Source: "LOCAL_INTERNET_RADIO",
Type: "stationurl",
Type: "stationurl",
Location: location,
ItemName: "My Station",
IsPresetable: true,
+88
View File
@@ -0,0 +1,88 @@
### Overview of Recent Improvements and Next Steps
This document summarizes the improvements made to the **Marge service** to improve parity with the upstream Bose SoundTouch service, along with open issues and proposed next steps.
#### ✅ Completed Improvements (Marge Service)
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` or `ButtonNumber` to the `buttonNumber` XML attribute in the `/full` response and ensured it is persisted in the local datastore.
* **High-Fidelity Device Metadata**: Improved the datastore to correctly extract, persist, and report detailed device `<components>` (e.g., `LIGHTSWITCH`, `SMSC`) and their firmware versions from upstream responses.
* **Standardized Preferred Language**: Updated the default `preferredLanguage` to `de` in the `/full` response and added synchronization to persist it from upstream responses.
* **Persisted Provider Settings**: Added support for persisting and echoing back `providerSettings` (e.g., `STREAMING_QUALITY`, `ELIGIBLE_FOR_TRIAL`) from the `/full` response.
* **Populated `contentItemType`**: The `contentItemType` (e.g., `tracklisturl`) is now correctly synchronized from upstream, persisted in the local datastore, and returned in the `/full` response for both presets and recents.
* **Standardized Credential Types**: Adjusted the logic for Spotify to use the correct `token_version_3` type when a token is present in the `/full` response, improving parity with the upstream service. The service now respects existing `credential_type` values from `Sources.xml` (e.g., `token_version_3` for Spotify) while providing sensible defaults for new or incomplete sources.
* **Structured Sources (Sources.xml)**: Refactored `Sources.xml` to use an attribute-based structure (`sourceid`, `source`, `status`, `sourceAccount`, etc.) matching the real device's output. Removed redundant nested tags like `<sourcename>`, `<username>`, and `<name>`.
* **Nested Recents (Recents.xml)**: Implemented a nested `<contentItem>` structure within `<recent>` entries in `Recents.xml`, maintaining exact parity with the device's persistence format while supporting legacy flat formats for backward compatibility.
* **Inconsistent `serialNumber` Casing**: Fixed the casing mismatch in the `/full` response where the upstream uses camelCase `<serialNumber>` in the top-level `<device>` and lowercase `<serialnumber>` in the nested `<attachedProduct>`. Local responses now correctly mirror this inconsistency.
* **Attribute-level Parity**:
* Ensured `sourceAccount=""` is preserved in XML even when empty, matching device behavior for sources like TUNEIN.
* Fixed casing for attributes like `deviceID` and `utcTime` in `Recents.xml`.
* Correctly mapped and persisted preset and recent `id` attributes during "Initial Data Sync".
* **Device Name Consistency**: Fixed an issue where the device `<name>` was empty in some local `/full` responses by ensuring it is correctly populated from the datastore and synchronized from upstream.
* **Improved XML Parity**: Empty `<name>` tags in the `/full` response are now self-closing (`<name/>`), matching upstream behavior.
* **Timestamp-based ID Generation**: Implemented a 9-digit ID schema (`YYMMDD` + 3-digit counter) for `recent` items, ensuring IDs are large, unique, and stay within the 32-bit integer range.
* **Automatic Source Learning**: The service now extracts and persists full metadata (credentials, provider IDs, and custom names) from incoming `POST /recent` requests. This improves parity for subsequent `GET /recents` calls.
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
* **Credential Preservation**: Improved `AddRecent` to correctly extract and echo back base64 tokens/credentials provided in the incoming request, improving source learning.
* **XML Formatting Parity**:
* Added `standalone="yes"` to the XML declaration for all Marge responses, including `recent`, `presets`, `full account`, `software update`, and `sourceproviders`.
* Enforced self-closing `<sourceSettings/>` tags for parity.
* Standardized date formatting to UTC with milliseconds (`.000+00:00`).
* Fixed casing for `/streaming/sourceproviders`: Root element is `<sourceProviders>`, but child elements are `<sourceprovider>` (all lowercase), matching upstream behavior.
* Implemented structured XML marshaling with consistent 2-space indentation for recents and source providers.
* **Improved TuneIn Parity**: Fixed TuneIn source mapping to use ID `25` and ensuring `sourcename` is empty in responses, matching upstream behavior for station playback.
* **High-Fidelity Full Account Sync**: Refactored the `/streaming/account/{accountId}/full` response to match the upstream structure. This includes:
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` to the `buttonNumber` XML attribute in the `/full` response.
* **Structured XML Marshaling**: Replaced manual string concatenation with structured Go models and `xml.Marshal` for the entire response.
* **Specific Response Models**: Introduced `FullResponseSource`, `FullResponsePreset`, and `FullResponseRecent` to accurately reflect the upstream structure where `<source>` is a child element, rather than a set of attributes.
* **Correct Nesting**: Ensured that `<presets>` and `<recents>` correctly nest their associated `<source>` details, resolving previous data omissions.
* **Device Identity**: Added `<serialNumber>` and `<updatedOn>` to both the top-level `<device>` and its `<attachedProduct>`, ensuring consistent device identification.
* **Field-Level Parity**: Mapped missing fields like `<contentItemType>` and `<productlabel>` to match upstream expectations.
* **Improved Source Matching**: Enhanced internal logic to correctly link presets and recents to their configured sources based on multiple identifiers (ID, Key, or Type).
* **Verified Parity Mismatch Fixes**: Comprehensive reproduction tests (`TestParityMismatchReproduction_V2` and `TestParityMismatchReproduction_V3`) now confirm parity for identified mismatches in `POST /recent` and `GET /recents`, including credentials and source-specific metadata.
* **Unified Response Logic**: Refactored the code so that both `POST /recent` and `GET /recents` use the same formatting functions, guaranteeing consistency.
* **Robust Parity Detection**: Updated the local parity checker to be whitespace-insensitive for XML bodies, significantly reducing noise from minor indentation or newline differences.
* **Maintainable XML Generation**: Reduced cyclomatic complexity and code duplication in `marge.go` by extracting focused helper functions for mapping internal data to response-specific XML models.
---
#### 🛠️ Open Issues and Next Steps
Based on the latest `parity_mismatches` and the high-fidelity `/full` account response comparison (diff14), here are the recommended areas for further work:
#### 1. BMX / TuneIn Playback Parity (Medium)
Current mismatches in `/bmx/tunein/v1/playback/station/...` show differences in reporting URLs and missing links:
* **Mismatched Parameters**: Local reporting URLs use `listen_id=1234567890`, while upstream uses a different session-based ID.
* **Missing Links**: Some upstream responses include additional `_links` or metadata that are currently omitted in local responses.
* **Action**: Improve the `HandleTuneInPlayback` logic to better mirror the upstream response structure and parameter generation.
#### 2. `/full` Account Response Data Gaps (Medium)
While structural parity for the `/full` response is high, several value-level gaps remain as shown in `diff14`:
* **Timestamp Formats**: Upstream uses ISO-8601 with milliseconds (e.g., `2024-06-23T07:40:36.000+00:00`), whereas some local fields still use Unix epoch integers (e.g., `1234567890`).
* **Provider Settings**: The `providerSettings` block in the local response currently lacks crucial values like `keyName`, `providerId`, and `boseId` (appearing as empty tags).
* **Component Metadata**: Local component types are sometimes empty (`type=""`) compared to upstream values like `LIGHTSWITCH` or `SMSC`.
* **Source/Preset Identifiers**: Local IDs (e.g., `100004`) differ from upstream IDs (e.g., `1234567`), though this may be expected due to different account/device environments.
* **Action**: Update the mapping logic in `marge.go` and `setup.go` to ensure all fields in the `/full` response are correctly populated with high-fidelity values and standard ISO-8601 timestamps.
#### 3. OAuth / Spotify Token Noise (Low/Medium)
The `/oauth/device/.../token` endpoint frequently reports mismatches because tokens are naturally different between local and upstream.
* **The Issue**: This creates "noise" in your parity reports that isn't actually a bug.
* **Action**: Update the parity detection logic (or the handler) to selectively ignore the `access_token` field while still verifying that the rest of the JSON structure (expires_in, scope, token_type) matches.
#### 4. Large IDs for Other Models (Medium)
While we fixed IDs for `recents`, other models like `presets` or `sources` might still use small auto-incrementing integers.
* **Action**: Evaluate if other endpoints should also transition to the timestamp-based ID schema to further reduce diff noise.
#### 5. Improved Data Persistence (Continuous)
Continue the "learning" approach for other services. For example, if we see a new `sourceproviderid` in a Spotify or TuneIn request, we should ensure it is stored and reused.
#### 6. Local Reboot & Device State Management (Continuous)
Analysis of device reboot logs revealed several data requirements:
* **Power-On Details Tracking**: Implemented extraction and persistence of detailed device information (serial numbers, firmware version, product details, and MAC addresses) from the `POST /streaming/support/power_on` request. This data is now stored in the local datastore, improving our ability to respond accurately to subsequent management requests.
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
#### 7. Account Full Response (/full) Structural & Value Parity (Completed)
Structural and value gaps in the `/full` account response have been addressed:
**Key Fixes:**
* **Structural**:
* **Nested Source Association**: Improved the matching logic in `mapRecentsToFullResponse` to correctly link recents to their specific `ConfiguredSource` (e.g., by matching `sourceid` attribute).
* **XML Tag Formatting**: Standardized self-closing tags and element formatting to match upstream's multi-line or empty-element formatting in various contexts.
+41
View File
@@ -0,0 +1,41 @@
# Parity Analysis: Bose-SoundTouch (Go) vs. SoundCork (Python)
This document provides a comparative analysis of the current Go implementation and the `deborahgu/soundcork` project, identifying functional gaps and potential improvements.
## 1. Core Architecture and Language
- **Bose-SoundTouch (Go)**: Uses `chi` for routing and `encoding/xml` for data. High performance, strong typing, and precise MIME type handling (`application/vnd.bose.streaming-v1.2+xml`).
- **SoundCork (Python)**: Uses `FastAPI` and `xml.etree.ElementTree`. Prioritizes flexibility and rapid prototyping of streaming service mocks.
## 2. Functional Comparison
| Feature | Bose-SoundTouch (Go) | SoundCork (Python) |
|:---------------------|:-------------------------------------------------|:----------------------------------------------------------------------------------------|
| **Group Management** | Placeholder handlers (return `<group/>` or 404). | Active group management (`groups.py`), supporting `/addGroup` and stereo pairing logic. |
| **BMX Services** | Supports TuneIn, Orion, and custom streams. | More modular `bmx_services.json` registry with broader mock support. |
| **Persistence** | Mixed JSON/XML datastore. | Pure XML-based persistence per device/account. |
| **Admin UI** | CLI-based (`soundtouch-cli`) or API-driven. | Draft Web UI for device discovery and account management (`admin.py`). |
| **Discovery** | Integrated setup tools and SSDP/MDNS awareness. | Leverages `bosesoundtouchapi` Python library for active discovery. |
## 3. Key Strengths of SoundCork
- **Group Pairing Logic**: Includes logic to manage master/slave relationships for SoundTouch 10 stereo pairs.
- **Service Extensibility**: JSON-based registry for BMX services makes it easier to mock multiple providers (SiriusXM, Spotify) without code changes.
- **Mock Coverage**: Better coverage of "dummy" endpoints that respond with plausible XML (e.g., `customerSupport`).
## 4. Suggested Implementation Steps for Bose-SoundTouch
### A. Implement Full Group Support (High Priority)
- Add logic to `pkg/service/marge` to handle `/addGroup` and `/updateGroup`.
- Persist group memberships in the datastore to allow speakers to function as stereo pairs or multi-room zones.
### B. Modularize BMX Registry (Medium Priority)
- Extract the hardcoded service list in `HandleBMXRegistry` into an external `bmx-services.json` file.
- Allow users to customize which mocked services are advertised to the speaker.
### C. Enhanced Source Management (Medium Priority)
- Refine source learning logic to ensure all `sourceAccount` and `sourceName` metadata is correctly captured during synchronization, using patterns from `soundcork`'s `learnSource`.
### D. Basic Admin Web UI (Low Priority)
- Develop a minimal internal status page to list active accounts and connected devices, improving usability over raw API calls.
## 5. Summary
While our Go implementation is structurally more consistent with recent reference recordings (e.g., `buttonNumber`, detailed `components`), SoundCork provides better coverage of multi-device coordination (Groups) and service emulation (BMX) that we should adopt for a more complete offline experience.
+1 -1
View File
@@ -119,7 +119,7 @@ soundtouch-service
```go
// Build custom applications on top of local services
client := &http.Client{}
resp, _ := client.Get("http://localhost:8000/devices")
resp, _ := client.Get("http://localhost:8000/setup/devices")
```
### Privacy-Conscious Users
+6 -1
View File
@@ -53,11 +53,14 @@
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
* [Stockholm App Analysis](analysis/stockholm-app-analysis.md)
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
## Parity Analysis
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
* [Parity SoundCork](PARITY-SOUNDCORK.md)
## Appendix (Other Documents)
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
* [Claude Instructions](CLAUDE.md)
@@ -81,3 +84,5 @@
* [Device Lifecycle Summary](device-lifecycle-summary.md)
* [Power On Implementation Guide](power-on-implementation-guide.md)
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
* [Parity SoundCork](PARITY-SOUNDCORK.md)
-52
View File
@@ -1,52 +0,0 @@
### Stockholm App Analysis Report
#### 1. Overview
The Stockholm app is a CEPE MAUI SoundTouch Controller HTML5/JS UI. It is designed to run as a web-based interface for Bose SoundTouch devices, likely served by the device itself or an associated controller.
- **Technology Stack**: HTML5, CSS3, JavaScript (Minified).
- **Key Libraries**:
- **jQuery**: Core DOM manipulation and event handling.
- **iScroll**: Used for smooth scrolling in lists and carousels.
- **Forge**: Used for cryptographic operations (likely for secure communication or authentication).
- **WebSocket Polyfill**: Ensures WebSocket compatibility across environments.
#### 2. Directory Structure
- `js/`: Core application logic.
- `app/`: Main application entry point (`app.js`).
- `models/`: Data models for UI components (Presets, Favorites, Onboarding, etc.).
- `music_services/`: Implementation of various music services (Amazon, Deezer, Spotify, BMX, etc.).
- `views/`: UI view templates and logic.
- `utils/`: Utility functions for security, data analytics, and general-purpose tasks.
- `json/`: Configuration files and static data.
- `config.json`: Core application configuration including Base64 encoded Bose API endpoints (e.g., streaming, events, BMX registry).
- `sourceFeatures.json`: Capability mapping for different sources.
- `setup/`: Onboarding and initial device setup logic.
- `lang/`: Localization files for multi-language support.
#### 3. Communication Architecture
The app uses several communication channels to interact with the SoundTouch ecosystem:
- **Socket Communication (`socket_comm.js`)**: Real-time updates and low-latency commands via WebSockets.
- **BMX (`bmx.js` & `js/music_services/bmx/`)**: Interactions with the Bose Music eXperience services. Handles account management, navigation, and API response validation.
- **Marge (`marge_comm.js`)**: Likely used for interaction with the Marge service (Bose's legacy cloud/proxy service).
- **Worker-based Architecture**: Many services use Web Workers (`bmx_worker.js`, `spotify_worker.js`) to handle API requests and data processing in the background, keeping the UI responsive.
#### 4. Key Features & Functionality
- **Multi-Device Management**: Discovering and controlling multiple speakers on the network.
- **Music Service Integration**: Deep integration with Spotify, Amazon Music, Deezer, and Pandora.
- **Preset Management**: Browsing and setting presets directly from the UI.
- **Zone Control**: Creating and managing multi-room groups (Master/Slave configurations).
- **Onboarding**: A dedicated setup flow for new devices.
- **Analytics & Data Collection**: Modules like `data_analytics.js` and `dc_server.js` suggest tracking of user interactions.
#### 5. Integration Opportunities for Bose-SoundTouch Project
Based on the Stockholm app's capabilities, the following features could be enhanced or added to our Go-based `soundtouch-service`:
1. **Enhanced BMX Emulation**: Use insights from `bmx_client.js` and `bmx_navigate_response_generator.js` to improve our local BMX implementation.
2. **Spotify/Amazon Service Proxies**: Implement the backend logic required to support the same API calls the Stockholm app makes to these services.
3. **UI parity**: The Stockholm app's view templates (`views/`) can serve as a reference for our Web Management UI.
4. **WebSocket Support**: Ensure our service provides a robust WebSocket interface similar to what the Stockholm app expects for real-time state synchronization.
5. **Capability Discovery**: Better utilization of the `sourceFeatures.json` logic to dynamically show/hide features based on the device model and firmware version.
#### 6. Conclusion
The Stockholm app is a mature, full-featured controller that relies heavily on Bose's proprietary BMX and Marge services. By analyzing its client-side logic, we can better understand the expected API responses and interaction patterns needed to provide a seamless local replacement for the Bose Cloud.
+12 -2
View File
@@ -394,6 +394,9 @@ soundtouch-cli --host <device> source spotify
soundtouch-cli --host <device> source bluetooth
soundtouch-cli --host <device> source aux
# Custom radio selection (via soundtouch-service)
soundtouch-cli --host <device> source custom-radio --url <STREAM_URL> [--name <NAME>] [--artwork <ARTWORK>] [--service-url <SERVICE_URL>]
# Advanced content selection
soundtouch-cli --host <device> source internet-radio --location <URL> [--name <NAME>]
soundtouch-cli --host <device> source local-music --location <LOCATION> --account <ACCOUNT>
@@ -482,6 +485,7 @@ soundtouch-cli --host 192.168.1.10 source compare
| Command | Description | Requirements |
|---------|-------------|--------------|
| `internet-radio` | Select internet radio stream (LOCAL_INTERNET_RADIO) | Stream URL |
| `custom-radio` | Select custom radio stream via soundtouch-service | Stream URL and service URL |
| `local-music` | Select local music content (LOCAL_MUSIC) | SoundTouch App Media Server |
| `stored-music` | Select stored music content (STORED_MUSIC) | UPnP/DLNA media server |
| `content` | Generic content selection (advanced) | Source and location |
@@ -495,6 +499,12 @@ The `internet-radio` command supports the streamUrl proxy format from the [Sound
soundtouch-cli --host 192.168.1.10 source internet-radio \
--location "http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp" \
--name "Antenne Chillout"
# Using local soundtouch-service for custom streams
soundtouch-cli --host 192.168.1.10 source custom-radio \
--url "https://stream.antenne.de/chillout/stream/aacp" \
--name "Antenne Chillout" \
--service-url "http://localhost:8080"
```
#### Service Introspection
@@ -1044,7 +1054,7 @@ soundtouch-cli --host 192.168.1.10 speaker beep
**Supported Languages for TTS:**
- `EN` - English (default)
- `DE` - German
- `DE` - German
- `ES` - Spanish
- `FR` - French
- `IT` - Italian
@@ -1085,7 +1095,7 @@ soundtouch-cli --host <device> events subscribe [flags]
**Event Types:**
- `nowPlaying` - Track changes, playback status
- `volume` - Volume and mute changes
- `volume` - Volume and mute changes
- `connection` - Network connectivity status
- `preset` - Preset configuration changes
- `zone` - Multiroom zone changes
+24 -50
View File
@@ -26,10 +26,11 @@ The service consists of several key components:
### BMX Services (Bose Media eXchange)
- **TuneIn Integration**: Direct playback of radio stations and podcasts
- **Custom Streams**: Flexible playback of any internet radio URL via dynamic proxy
- **Service Registry**: Media service discovery and configuration
- **Playback Control**: Stream URL resolution and audio metadata
### Marge Services (Account & Device Management)
### Marge Services (Account & Device Management)
- **Account Management**: User account simulation and device association
- **Preset Synchronization**: Cross-device preset storage and sync
- **Recent Items**: Playback history tracking and management
@@ -57,7 +58,7 @@ go build -o soundtouch-service ./cmd/soundtouch-service
### Docker Support
You can run the SoundTouch service using Docker or Docker Compose.
You can run the SoundTouch service using Docker or Docker Compose.
> **Note for macOS and Windows users**: The `--net host` option is only supported on Linux. On macOS and Windows, service discovery (mDNS, UPnP) will not work automatically within the container. You will need to manually enter your device's IP address in the management UI, and the service will communicate with it directly.
@@ -212,23 +213,23 @@ Device migration switches your SoundTouch devices from Bose's cloud services to
```bash
# Get migration summary first
curl http://localhost:8000/setup/devices/192.168.1.100/summary
curl http://localhost:8000/setup/migration-summary/192.168.1.100
# Perform migration
curl -X POST http://localhost:8000/setup/devices/192.168.1.100/migrate
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
# Verify migration status
curl http://localhost:8000/devices
curl http://localhost:8000/setup/devices
```
#### Advanced Migration Options
```bash
# Migration with proxy fallback for original services
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?proxy_url=http://localhost:8000&marge=original&stats=original"
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
# Migration with custom target URL
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?target_url=https://my-server.com:8000"
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
```
### Post-Migration Verification
@@ -237,13 +238,13 @@ After migration, verify the device is working correctly:
```bash
# Check device status
curl http://localhost:8000/devices
curl http://localhost:8000/setup/devices
# Test preset functionality
curl "http://192.168.1.100:8090/presets"
# Monitor device events (if needed)
curl "http://localhost:8000/devices/08DF1F0BA325/events"
curl "http://localhost:8000/events/192.168.1.100"
```
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
@@ -347,7 +348,7 @@ Mirrored requests are also recorded in the **Interaction Log** under the categor
### Discovery & Setup
#### `GET /devices`
#### `GET /setup/devices`
Lists all discovered SoundTouch devices with their current status.
**Response:**
@@ -368,10 +369,10 @@ Lists all discovered SoundTouch devices with their current status.
#### `POST /setup/discover`
Triggers immediate network device discovery.
#### `GET /devices/{deviceIP}/info`
#### `GET /setup/info/{deviceIP}`
Gets detailed device information and configuration.
#### `GET /setup/devices/{deviceIP}/summary`
#### `GET /setup/migration-summary/{deviceIP}`
Analyzes device configuration and provides migration preview.
**Response:**
@@ -388,44 +389,17 @@ Analyzes device configuration and provides migration preview.
}
```
#### `POST /setup/devices/{deviceIP}/migrate`
#### `POST /setup/migrate/{deviceIP}`
Migrates device to use local services.
**Query Parameters:**
- `target_url`: Custom service URL (optional)
- `proxy_url`: Proxy URL for fallback (optional)
- `proxy_url`: Proxy URL for fallback (optional)
- `marge`: Set to "original" to proxy Marge requests (optional)
- `stats`: Set to "original" to proxy stats requests (optional)
- `sw_update`: Set to "original" to proxy update requests (optional)
- `bmx`: Set to "original" to proxy BMX requests (optional)
#### `POST /setup/devices/{deviceIP}/revert`
Reverts device to Bose cloud defaults.
#### `POST /setup/devices/{deviceIP}/trust-ca`
Injects the AfterTouch root CA into the device's trust store.
#### `POST /setup/devices/{deviceIP}/sync`
Syncs presets and recents from the device to local storage.
#### `POST /setup/devices/{deviceIP}/backup`
Creates a backup of the current device configuration.
#### `POST /setup/devices/{deviceIP}/ensure-remote-services`
Enables persistent SSH/remote services on the device.
#### `POST /setup/devices/{deviceIP}/remove-remote-services`
Removes persistent SSH/remote services from the device.
#### `POST /setup/devices/{deviceIP}/test-connection`
Tests HTTPS connection from device to service.
#### `POST /setup/devices/{deviceIP}/test-hosts`
Tests /etc/hosts redirection on the device.
#### `POST /setup/devices/{deviceIP}/test-dns`
Tests DNS redirection on the device.
### BMX Services (Bose Media eXchange)
#### `GET /bmx/registry/v1/services`
@@ -685,13 +659,13 @@ find data/stats/ -name "*.json" -mtime +90 -delete
- **Description**: Browser-based guided flow for discovery, data sync, and migration.
### Setup API
- `GET /devices`: List all known (auto-discovered and manual) devices.
- `POST /devices`: Manually add a device by IP.
- `GET /setup/devices`: List all known (auto-discovered and manual) devices.
- `POST /setup/devices`: Manually add a device by IP.
- `POST /setup/discover`: Trigger a new network discovery scan.
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
- `POST /devices/{deviceIP}/sync`: Fetch presets, recents, and sources from a device.
- `GET /devices/{deviceIP}/summary`: Get a detailed migration readiness summary.
- `POST /devices/{deviceIP}/migrate`: Migrate a device using the specified method (XML/Hosts).
- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device.
- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary.
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
#### `GET /setup/interactions`
@@ -791,7 +765,7 @@ ls -la data/events/
This service implementation is based on and inspired by several excellent community projects:
### SoundCork
- **Project**: [SoundCork](https://github.com/deborahgu/soundcork)
- **Project**: [SoundCork](https://github.com/deborahgu/soundcork)
- **Authors**: Deborah Gu and contributors
- **Contribution**: The architecture and service emulation approach in this Go implementation is heavily based on SoundCork's pioneering Python implementation. SoundCork provided the foundation for understanding Bose's service architecture and migration strategies.
@@ -823,7 +797,7 @@ func customBMXHandler(w http.ResponseWriter, r *http.Request) {
func main() {
r := chi.NewRouter()
r.Get("/bmx/custom/endpoint", customBMXHandler)
r.Get("/custom/endpoint", customBMXHandler)
http.ListenAndServe(":8000", r)
}
```
@@ -836,9 +810,9 @@ soundtouch:
- host: 192.168.1.100
port: 8090
name: "Living Room Speaker"
rest:
- resource: "http://localhost:8000/devices"
- resource: "http://localhost:8000/setup/devices"
scan_interval: 60
sensor:
- name: "SoundTouch Devices"
+10 -6
View File
@@ -8,16 +8,20 @@ require (
github.com/hashicorp/mdns v1.0.6
github.com/miekg/dns v1.1.72
github.com/russross/blackfriday/v2 v2.1.0
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.48.0
golang.org/x/crypto v0.49.0
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/image v0.38.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect
)
+22 -14
View File
@@ -13,6 +13,10 @@ github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
@@ -24,16 +28,18 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -44,8 +50,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -53,8 +59,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -67,8 +73,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -79,8 +85,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -91,6 +97,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -98,6 +106,6 @@ golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+4 -4
View File
@@ -66,7 +66,7 @@ func TestNewClientFromHost(t *testing.T) {
func TestGetDeviceInfo_Success(t *testing.T) {
// Load test data
testData := loadTestData(t, "info_response.xml")
testData := loadTestData(t, "info_response_st10.xml")
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -117,8 +117,8 @@ func TestGetDeviceInfo_Success(t *testing.T) {
t.Errorf("Expected Name 'My SoundTouch Device', got '%s'", deviceInfo.Name)
}
if deviceInfo.MargeAccountUUID != "3230304" {
t.Errorf("Expected MargeAccountUUID '3230304', got '%s'", deviceInfo.MargeAccountUUID)
if deviceInfo.MargeAccountUUID != "1234567" {
t.Errorf("Expected MargeAccountUUID '1234567', got '%s'", deviceInfo.MargeAccountUUID)
}
if deviceInfo.ModuleType != "sm2" {
@@ -227,7 +227,7 @@ func TestGetDeviceInfo_APIError(t *testing.T) {
}
func TestPing_Success(t *testing.T) {
testData := loadTestData(t, "info_response.xml")
testData := loadTestData(t, "info_response_st10.xml")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/xml")
@@ -2,7 +2,7 @@
<info deviceID="ABCD1234EFGH">
<name>My SoundTouch Device</name>
<type>SoundTouch 10</type>
<margeAccountUUID>3230304</margeAccountUUID>
<margeAccountUUID>1234567</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
+1 -1
View File
@@ -2,7 +2,7 @@
<info deviceID="ABCD1234EFGH">
<name>My SoundTouch Device</name>
<type>SoundTouch 20</type>
<margeAccountUUID>3230304</margeAccountUUID>
<margeAccountUUID>1234567</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
+32 -7
View File
@@ -2,6 +2,7 @@ package client
import (
"context"
"encoding/xml"
"fmt"
"log"
"net/url"
@@ -356,13 +357,6 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
}
attempt++
// Check if device is reachable before attempting full WS connection to reduce log noise
if err := ws.client.Ping(); err != nil {
ws.logger.Printf("Reconnection attempt %d skipped: device unreachable (%v)", attempt, err)
continue
}
ws.logger.Printf("Reconnection attempt %d", attempt)
if err := ws.connectWithConfig(config); err != nil {
@@ -526,6 +520,37 @@ func (ws *WebSocketClient) SendMessage(message []byte) error {
return conn.WriteMessage(websocket.TextMessage, message)
}
// PairWithAccount sends a request to pair the device with a specific account
func (ws *WebSocketClient) PairWithAccount(accountID, userAuthToken string) error {
request := models.PairDeviceWithAccount{
AccountID: accountID,
UserAuthToken: userAuthToken,
}
data, err := xml.Marshal(request)
if err != nil {
return fmt.Errorf("failed to marshal pairing request: %w", err)
}
ws.logger.Printf("Sending PairDeviceWithAccount for account %s", accountID)
return ws.SendMessage(data)
}
// UnPairFromAccount sends a request to unpair the device from its account
func (ws *WebSocketClient) UnPairFromAccount() error {
request := models.UnPairDeviceWithAccount{}
data, err := xml.Marshal(request)
if err != nil {
return fmt.Errorf("failed to marshal unpairing request: %w", err)
}
ws.logger.Printf("Sending UnPairDeviceWithAccount")
return ws.SendMessage(data)
}
// Wait blocks until the WebSocket connection is closed or context is cancelled
func (ws *WebSocketClient) Wait() {
<-ws.ctx.Done()
+380 -22
View File
@@ -132,42 +132,239 @@ type SourceProvider struct {
// ServiceContentItem represents a media content item with source and location details.
type ServiceContentItem struct {
ID string `json:"id" xml:"id,attr"`
Name string `json:"name" xml:"itemName"`
Source string `json:"source,omitempty" xml:"source,attr,omitempty"`
Type string `json:"type" xml:"type,attr"`
Location string `json:"location" xml:"location,attr"`
SourceAccount string `json:"source_account,omitempty" xml:"sourceAccount,attr,omitempty"`
SourceID string `json:"source_id,omitempty" xml:"sourceid,omitempty"`
IsPresetable string `json:"is_presetable,omitempty" xml:"isPresetable,attr,omitempty"`
ID string `json:"id" xml:"id,attr"`
Name string `json:"name" xml:"name"`
Source string `json:"source,omitempty" xml:"source,attr,omitempty"`
Type string `json:"type" xml:"type,attr"`
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
Location string `json:"location,omitempty" xml:"location,attr,omitempty"`
SourceAccount string `json:"source_account" xml:"sourceAccount,attr"`
SourceID string `json:"source_id,omitempty" xml:"sourceid,omitempty"`
IsPresetable string `json:"is_presetable,omitempty" xml:"isPresetable,attr,omitempty"`
}
// ServicePreset represents a user-defined preset for quick access to media content.
type ServicePreset struct {
ServiceContentItem
ContainerArt string `json:"container_art" xml:"containerArt"`
CreatedOn string `json:"created_on" xml:"createdOn"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
ID string `json:"id,omitempty" xml:"id,attr"`
ContainerArt string `json:"container_art" xml:"containerArt"`
CreatedOn string `json:"created_on" xml:"createdOn"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
ButtonNumber string `json:"button_number,omitempty" xml:"buttonNumber,attr,omitempty"`
Username string `json:"-" xml:"username,omitempty"`
SourceConfig *ConfiguredSource `json:"-" xml:"source,omitempty"`
}
// ServiceRecent represents recently played media content.
type ServiceRecent struct {
XMLName xml.Name `json:"-" xml:"recent"`
ServiceContentItem
DeviceID string `json:"device_id" xml:"deviceid"`
UtcTime string `json:"utc_time" xml:"utc_time"`
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
DeviceID string `json:"device_id" xml:"deviceID,attr"`
UtcTime string `json:"utc_time" xml:"utcTime,attr"`
CreatedOn string `json:"created_on,omitempty" xml:"createdOn"`
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn"`
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
SourceConfig *ConfiguredSource `json:"-" xml:"source,omitempty"`
LastPlayedAt string `json:"last_played_at,omitempty" xml:"lastplayedat"`
ContentItem *struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt,omitempty"`
} `xml:"contentItem,omitempty"`
}
// UnmarshalXML implements the xml.Unmarshaler interface to handle both nested and flat formats.
func (r *ServiceRecent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
type ContentItem struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt,omitempty"`
}
type Alias struct {
XMLName xml.Name `xml:"recent"`
ServiceContentItem
DeviceID string `xml:"deviceID,attr"`
UtcTime string `xml:"utcTime,attr"`
ID string `xml:"id,attr"`
CreatedOn string `xml:"createdOn,omitempty"`
UpdatedOn string `xml:"updatedOn,omitempty"`
ContainerArt string `xml:"containerArt,omitempty"`
SourceConfig *ConfiguredSource `xml:"source,omitempty"`
LastPlayedAt string `xml:"lastplayedat"`
ContentItem *ContentItem `xml:"contentItem,omitempty"`
}
var a Alias
if err := d.DecodeElement(&a, &start); err != nil {
return err
}
r.DeviceID = a.DeviceID
r.UtcTime = a.UtcTime
r.ID = a.ID
r.SourceID = a.SourceID
if r.SourceID == "" {
r.SourceID = a.SourceID
}
r.CreatedOn = a.CreatedOn
r.UpdatedOn = a.UpdatedOn
r.ContainerArt = a.ContainerArt
r.SourceConfig = a.SourceConfig
r.LastPlayedAt = a.LastPlayedAt
// Ensure the embedded ServiceContentItem.ID is populated from the attribute
r.ID = a.ID
if a.ContentItem != nil {
r.Source = a.ContentItem.Source
r.Type = a.ContentItem.Type
r.Location = a.ContentItem.Location
r.SourceAccount = a.ContentItem.SourceAccount
r.IsPresetable = a.ContentItem.IsPresetable
r.Name = a.ContentItem.ItemName
if a.ContentItem.ContainerArt != "" {
r.ContainerArt = a.ContentItem.ContainerArt
}
} else {
// Fallback for flat format: populate ContentItem fields from root fields
r.Source = a.Source
r.Type = a.Type
r.Location = a.Location
r.SourceAccount = a.SourceAccount
r.IsPresetable = a.IsPresetable
r.Name = a.Name
}
// Always ensure the nested struct is populated for MarshalXML
r.ContentItem = &struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt,omitempty"`
}{
Source: r.Source,
Type: r.Type,
Location: r.Location,
SourceAccount: r.SourceAccount,
IsPresetable: r.IsPresetable,
ItemName: r.Name,
ContainerArt: r.ContainerArt,
}
return nil
}
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ServiceRecent.
func (r ServiceRecent) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
type ContentItem struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt,omitempty"`
}
type Alias struct {
XMLName xml.Name `xml:"recent"`
DeviceID string `xml:"deviceID,attr"`
UtcTime string `xml:"utcTime,attr"`
ID string `xml:"id,attr"`
ContentItem ContentItem `xml:"contentItem"`
CreatedOn string `xml:"createdOn,omitempty"`
UpdatedOn string `xml:"updatedOn,omitempty"`
LastPlayedAt string `xml:"lastplayedat,omitempty"`
SourceID string `xml:"sourceid,omitempty"`
Source *ConfiguredSource `xml:"source,omitempty"`
}
a := Alias{
DeviceID: r.DeviceID,
UtcTime: r.UtcTime,
ID: r.ID,
SourceID: r.SourceID,
ContentItem: ContentItem{
Source: r.Source,
Type: r.Type,
Location: r.Location,
SourceAccount: r.SourceAccount,
IsPresetable: r.IsPresetable,
ItemName: r.Name,
ContainerArt: r.ContainerArt,
},
CreatedOn: r.CreatedOn,
UpdatedOn: r.UpdatedOn,
LastPlayedAt: r.LastPlayedAt,
Source: r.SourceConfig,
}
if a.SourceID == "" && r.SourceID != "" {
a.SourceID = r.SourceID
}
if r.ContentItem != nil {
a.ContentItem.Source = r.ContentItem.Source
a.ContentItem.Type = r.ContentItem.Type
a.ContentItem.Location = r.ContentItem.Location
a.ContentItem.SourceAccount = r.ContentItem.SourceAccount
a.ContentItem.IsPresetable = r.ContentItem.IsPresetable
a.ContentItem.ItemName = r.ContentItem.ItemName
if r.ContentItem.ContainerArt != "" {
a.ContentItem.ContainerArt = r.ContentItem.ContainerArt
}
}
if a.Source == nil && r.SourceConfig != nil {
a.Source = r.SourceConfig
}
if a.ContentItem.IsPresetable == "" {
a.ContentItem.IsPresetable = "true"
}
start.Name.Local = "recent"
return e.EncodeElement(a, start)
}
// ConfiguredSource represents a configured media source with authentication details.
type ConfiguredSource struct {
DisplayName string `json:"display_name" xml:"displayName,attr"`
ID string `json:"id" xml:"id,attr"`
Secret string `json:"secret" xml:"secret,attr"`
SecretType string `json:"secret_type" xml:"secretType,attr"`
XMLName xml.Name `json:"-" xml:"source"`
DisplayName string `json:"display_name" xml:"displayName,attr,omitempty"`
ID string `json:"id" xml:"id,attr,omitempty"`
Secret string `json:"secret" xml:"secret,attr"`
SecretType string `json:"secret_type" xml:"secretType,attr"`
SourceKey struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
} `json:"source_key" xml:"sourceKey"`
Type string `xml:"type,attr,omitempty"`
// Parity fields
CreatedOn string `json:"created_on,omitempty" xml:"createdOn,attr,omitempty"`
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn,attr,omitempty"`
SourceProviderID string `json:"sourceproviderid,omitempty" xml:"sourceproviderid,attr,omitempty"`
Username string `json:"username,omitempty" xml:"-"`
SourceName string `json:"source_name,omitempty" xml:"-"`
Name string `json:"name,omitempty" xml:"-"`
SourceSettings string `json:"-" xml:"-"`
Status string `json:"status,omitempty" xml:"-"`
// Legacy fields for backward compatibility in code if needed,
// though it's better to update the code to use SourceKey.
@@ -175,6 +372,44 @@ type ConfiguredSource struct {
SourceKeyAccount string `json:"source_key_account" xml:"-"`
}
// MarshalXML implements the xml.Marshaler interface for custom XML encoding of ConfiguredSource.
func (s ConfiguredSource) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
type Alias struct {
DisplayName string `xml:"displayName,attr,omitempty"`
Secret string `xml:"secret,attr"`
SecretType string `xml:"secretType,attr"`
ID string `xml:"id,attr,omitempty"`
Type string `xml:"type,attr,omitempty"`
CreatedOn string `xml:"createdOn,attr,omitempty"`
UpdatedOn string `xml:"updatedOn,attr,omitempty"`
SourceProviderID string `xml:"sourceproviderid,attr,omitempty"`
SourceKey struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
} `xml:"sourceKey"`
}
a := Alias{
DisplayName: s.DisplayName,
Secret: s.Secret,
SecretType: s.SecretType,
ID: s.ID,
Type: s.Type,
CreatedOn: s.CreatedOn,
UpdatedOn: s.UpdatedOn,
SourceProviderID: s.SourceProviderID,
}
a.SourceKey.Type = s.SourceKey.Type
a.SourceKey.Account = s.SourceKey.Account
start.Name.Local = "source"
// Important: Clear automatically generated attributes from the start element
// because we are using Alias to control attribute order and presence.
start.Attr = nil
return e.EncodeElement(a, start)
}
// ServiceDeviceInfo represents information about a SoundTouch device.
type ServiceDeviceInfo struct {
DeviceID string `json:"device_id" xml:"deviceID,attr"`
@@ -192,10 +427,19 @@ type ServiceDeviceInfo struct {
// ServiceComponent represents a hardware or software component of a device.
type ServiceComponent struct {
Type string `xml:"type,attr"`
Category string `xml:"category,attr"`
SoftwareVersion string `xml:"softwareVersion"`
SerialNumber string `xml:"serialNumber"`
Type string `json:"type" xml:"type,attr"`
Category string `json:"category,omitempty" xml:"category,attr,omitempty"`
SoftwareVersion string `json:"firmware_version" xml:"firmware-version"`
SerialNumber string `json:"serial_number" xml:"serialnumber"`
Label string `json:"label,omitempty" xml:"componentlabel,omitempty"`
}
// ServiceAccountInfo represents account-level metadata.
type ServiceAccountInfo struct {
AccountID string `json:"account_id"`
PreferredLanguage string `json:"preferred_language"`
ProviderSettings []ProviderSetting `json:"provider_settings"`
IsPlaceholder bool `json:"is_placeholder,omitempty"`
}
// CustomerSupportDevice represents device information for customer support purposes.
@@ -317,3 +561,117 @@ type EmailAddressResponse struct {
XMLName xml.Name `xml:"emailAddress"`
Email string `xml:",chardata"`
}
// FullResponseSource represents a configured media source specifically for the /full response.
// It follows the specific XML structure and field order of the upstream /full response.
type FullResponseSource struct {
ID string `json:"id" xml:"id,attr"`
Type string `json:"type" xml:"type,attr"`
DisplayName string `json:"display_name,omitempty" xml:"displayName,attr,omitempty"`
CreatedOn string `json:"created_on" xml:"createdOn"`
Credential struct {
Type string `json:"type" xml:"type,attr"`
Value string `json:"value" xml:",chardata"`
} `json:"credential" xml:"credential"`
Name string `json:"name" xml:"name"`
SourceProviderID string `json:"sourceproviderid" xml:"sourceproviderid"`
SourceName string `json:"source_name" xml:"sourcename"`
SourceSettings string `json:"source_settings" xml:"sourceSettings"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
Username string `json:"username" xml:"username"`
Account string `json:"account,omitempty" xml:"account,attr,omitempty"`
SourceLabel string `json:"source_label" xml:"-"`
}
// FullResponsePreset represents a preset specifically for the /full response.
type FullResponsePreset struct {
ButtonNumber string `json:"button_number" xml:"buttonNumber,attr"`
ContainerArt string `json:"container_art" xml:"containerArt"`
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
CreatedOn string `json:"created_on" xml:"createdOn"`
Location string `json:"location" xml:"location"`
Name string `json:"name" xml:"name"`
Source FullResponseSource `json:"source" xml:"source"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
Username string `json:"username" xml:"username"`
}
// FullResponseRecent represents a recent item specifically for the /full response.
type FullResponseRecent struct {
ID string `json:"id" xml:"id,attr"`
ContentItemType string `json:"content_item_type" xml:"contentItemType"`
CreatedOn string `json:"created_on" xml:"createdOn"`
LastPlayedAt string `json:"last_played_at" xml:"lastplayedat"`
Location string `json:"location" xml:"location"`
Name string `json:"name" xml:"name"`
Source FullResponseSource `json:"source" xml:"source"`
SourceID string `json:"source_id" xml:"sourceid"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
}
// AccountFullResponse represents the complete account XML structure.
type AccountFullResponse struct {
XMLName xml.Name `xml:"account"`
ID string `xml:"id,attr"`
AccountStatus string `xml:"accountStatus"`
Devices []AccountDevice `xml:"devices>device"`
Mode string `xml:"mode"`
PreferredLanguage string `xml:"preferredLanguage"`
ProviderSettings []ProviderSetting `xml:"providerSettings>providerSetting"`
Sources []FullResponseSource `xml:"sources>source"`
}
// AccountDevice represents a device in the account response.
type AccountDevice struct {
DeviceID string `json:"device_id" xml:"deviceid,attr"`
AttachedProduct *AttachedProduct `json:"attached_product" xml:"attachedProduct"`
CreatedOn string `json:"created_on" xml:"createdOn"`
FirmwareVersion string `json:"firmware_version" xml:"firmwareVersion"`
IPAddress string `json:"ip_address" xml:"ipaddress"`
Name string `json:"name" xml:"name"`
Presets []FullResponsePreset `json:"presets" xml:"presets>preset"`
ProductCode string `json:"product_code" xml:"-"`
Recents []FullResponseRecent `json:"recents" xml:"recents>recent"`
SerialNumber string `json:"serial_number" xml:"serialNumber"`
DeviceSerialNumber string `json:"device_serial_number,omitempty" xml:"-"`
MacAddress string `json:"mac_address,omitempty" xml:"-"`
DiscoveryMethod string `json:"discovery_method,omitempty" xml:"-"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
}
// AttachedProduct represents product information for a device.
type AttachedProduct struct {
ProductCode string `json:"product_code" xml:"product_code,attr"`
Components []ServiceComponent `json:"components" xml:"components>component"`
ProductLabel string `json:"product_label" xml:"productlabel"`
SerialNumber string `json:"serial_number" xml:"serialnumber"`
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
}
// ProviderSetting represents a single provider setting.
type ProviderSetting struct {
BoseID string `json:"bose_id" xml:"boseId"`
KeyName string `json:"key_name" xml:"keyName"`
Value string `json:"value" xml:"value"`
ProviderID string `json:"provider_id" xml:"providerId"`
ProviderName string `json:"provider_name,omitempty" xml:"-"`
}
// MargeLoginRequest represents a login request from Stockholm.
type MargeLoginRequest struct {
XMLName xml.Name `xml:"login"`
Username string `xml:"username"`
Password string `xml:"password"`
}
// MargeAccountCreateRequest represents an account creation request from Stockholm.
type MargeAccountCreateRequest struct {
XMLName xml.Name `xml:"account"`
ID string `xml:"id,attr,omitempty"` // Optional ID for testing/overrides
FirstName string `xml:"firstName"`
LastName string `xml:"lastName"`
Email string `xml:"email"`
Password string `xml:"password"`
CountryCode string `xml:"countryCode"`
PreferredLanguage string `xml:"preferredLanguage"`
}
+20
View File
@@ -35,6 +35,10 @@ const (
EventTypeRecentsUpdated WebSocketEventType = "recentsUpdated"
// EventTypeLanguageUpdated indicates a language setting change
EventTypeLanguageUpdated WebSocketEventType = "languageUpdated"
// EventTypePairDeviceWithAccount indicates a device pairing request
EventTypePairDeviceWithAccount WebSocketEventType = "PairDeviceWithAccount"
// EventTypeUnPairDeviceWithAccount indicates a device unpairing request
EventTypeUnPairDeviceWithAccount WebSocketEventType = "UnPairDeviceWithAccount"
// EventTypeUnknown indicates an unrecognized event type
EventTypeUnknown WebSocketEventType = "unknown"
)
@@ -66,6 +70,10 @@ func (e WebSocketEventType) String() string {
return "Recents Updated"
case EventTypeLanguageUpdated:
return "Language Updated"
case EventTypePairDeviceWithAccount:
return "Pair Device With Account"
case EventTypeUnPairDeviceWithAccount:
return "UnPair Device With Account"
default:
return "Unknown Event"
}
@@ -299,6 +307,18 @@ type Language struct {
Value string `xml:",chardata"`
}
// PairDeviceWithAccount represents a device pairing request message
type PairDeviceWithAccount struct {
XMLName xml.Name `xml:"PairDeviceWithAccount"`
AccountID string `xml:"accountId"`
UserAuthToken string `xml:"userAuthToken"`
}
// UnPairDeviceWithAccount represents a device unpairing request message
type UnPairDeviceWithAccount struct {
XMLName xml.Name `xml:"UnPairDeviceWithAccount"`
}
// SpecialMessageType represents message types that are not part of <updates>
type SpecialMessageType string
+28 -23
View File
@@ -280,6 +280,33 @@ func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error
return response, nil
}
// BuildCustomStreamResponse builds a playback response from streamUrl, imageUrl, and name.
func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPlaybackResponse, error) {
streamList := []models.Stream{
{
HasPlaylist: true,
IsRealtime: true,
StreamUrl: streamURL,
},
}
audio := models.Audio{
HasPlaylist: true,
IsRealtime: true,
StreamUrl: streamURL,
Streams: streamList,
}
response := &models.BmxPlaybackResponse{
Audio: audio,
ImageUrl: imageURL,
Name: name,
StreamType: "liveRadio",
}
return response, nil
}
// PlayCustomStream builds a playback response from a base64-encoded JSON blob
// with fields streamUrl, imageUrl, and name.
func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) {
@@ -302,27 +329,5 @@ func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) {
return nil, err
}
streamList := []models.Stream{
{
HasPlaylist: true,
IsRealtime: true,
StreamUrl: jsonObj.StreamURL,
},
}
audio := models.Audio{
HasPlaylist: true,
IsRealtime: true,
StreamUrl: jsonObj.StreamURL,
Streams: streamList,
}
response := &models.BmxPlaybackResponse{
Audio: audio,
ImageUrl: jsonObj.ImageURL,
Name: jsonObj.Name,
StreamType: "liveRadio",
}
return response, nil
return BuildCustomStreamResponse(jsonObj.StreamURL, jsonObj.ImageURL, jsonObj.Name)
}
+20 -1
View File
@@ -9,6 +9,7 @@ import (
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"time"
@@ -80,6 +81,10 @@ func (cm *CertificateManager) GetServerTLSConfig(domains []string) (*tls.Config,
domainMap[d] = true
}
for _, ip := range cert.IPAddresses {
domainMap[ip.String()] = true
}
for _, d := range domains {
if !domainMap[d] {
generate = true
@@ -237,6 +242,19 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
return nil, nil, err
}
var (
dnsNames []string
ipAddresses []net.IP
)
for _, domain := range domains {
if ip := net.ParseIP(domain); ip != nil {
ipAddresses = append(ipAddresses, ip)
} else {
dnsNames = append(dnsNames, domain)
}
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
@@ -247,7 +265,8 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: domains,
DNSNames: dnsNames,
IPAddresses: ipAddresses,
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, caCert, &priv.PublicKey, caKey)
@@ -126,3 +126,47 @@ func TestCertificateManager(t *testing.T) {
}
}
}
func TestCertificateManagerIPAddress(t *testing.T) {
tempDir, err := os.MkdirTemp("", "crypto-ip-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cm := NewCertificateManager(filepath.Join(tempDir, "certs"))
// Test certificate generation with an IP address
domains := []string{"192.168.1.100", "localhost"}
certPEM, _, err := cm.GenerateCertificate(domains)
if err != nil {
t.Fatalf("Failed to generate certificate: %v", err)
}
// Verify generated certificate
block, _ := pem.Decode(certPEM)
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("Failed to parse certificate: %v", err)
}
// Check IP addresses
foundIP := false
for _, ip := range cert.IPAddresses {
if ip.String() == "192.168.1.100" {
foundIP = true
break
}
}
if !foundIP {
t.Errorf("Expected IP address 192.168.1.100 in IPAddresses, but it was not found")
}
// Check if it was mistakenly added to DNSNames
for _, dns := range cert.DNSNames {
if dns == "192.168.1.100" {
t.Errorf("IP address 192.168.1.100 should NOT be in DNSNames")
}
}
}
+117
View File
@@ -1,6 +1,120 @@
// Package constants defines file names, directories, and common values used by the service layer.
package constants
import "strconv"
// SourceProvider represents a media source provider configuration.
type SourceProvider struct {
ID int
Name string
Label string
CreatedOn string
UpdatedOn string
}
// StaticProviders lists known source provider identifiers with their metadata.
var StaticProviders = []SourceProvider{
{ID: 1, Name: "PANDORA", Label: "Pandora", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
{ID: 2, Name: "INTERNET_RADIO", Label: "Internet Radio", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
{ID: 3, Name: "OFF", Label: "Off", CreatedOn: "2012-10-22T16:03:00.000+00:00", UpdatedOn: "2012-10-22T16:03:00.000+00:00"},
{ID: 4, Name: "LOCAL", Label: "Local", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 5, Name: "AIRPLAY", Label: "AirPlay", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 6, Name: "CURRATED_RADIO", Label: "Curated Radio", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 7, Name: "STORED_MUSIC", Label: "Stored Music", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 8, Name: "SLAVE_SOURCE", Label: "Slave Source", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 9, Name: "AUX", Label: "Aux", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 10, Name: "RECOMMENDED_INTERNET_RADIO", Label: "Recommended Internet Radio", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
{ID: 11, Name: "LOCAL_INTERNET_RADIO", Label: "Local Internet Radio", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
{ID: 12, Name: "GLOBAL_INTERNET_RADIO", Label: "Global Internet Radio", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
{ID: 13, Name: "HELLO", Label: "Hello", CreatedOn: "2014-03-17T15:30:07.000+00:00", UpdatedOn: "2014-03-17T15:30:07.000+00:00"},
{ID: 14, Name: "DEEZER", Label: "Deezer", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: 15, Name: "SPOTIFY", Label: "Spotify", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: 16, Name: "IHEART", Label: "iHeartRadio", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: 17, Name: "SIRIUSXM", Label: "SiriusXM", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 18, Name: "GOOGLE_PLAY_MUSIC", Label: "Google Play Music", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 19, Name: "QQMUSIC", Label: "QQMusic", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 20, Name: "AMAZON", Label: "Amazon Music", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 21, Name: "LOCAL_MUSIC", Label: "Local Music Library", CreatedOn: "2015-07-13T12:00:00.000+00:00", UpdatedOn: "2015-07-13T12:00:00.000+00:00"},
{ID: 22, Name: "WBMX", Label: "WBMX", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 23, Name: "SOUNDCLOUD", Label: "SoundCloud", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 24, Name: "TIDAL", Label: "Tidal", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 25, Name: "TUNEIN", Label: "TuneIn Radio", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 26, Name: "QPLAY", Label: "QPlay", CreatedOn: "2016-06-17T18:00:54.000+00:00", UpdatedOn: "2016-06-17T18:00:54.000+00:00"},
{ID: 27, Name: "JUKE", Label: "Juke", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 28, Name: "BBC", Label: "BBC", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 29, Name: "DARFM", Label: "DAR.fm", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 30, Name: "7DIGITAL", Label: "7digital", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 31, Name: "SAAVN", Label: "Saavn", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 32, Name: "RDIO", Label: "Rdio", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 33, Name: "PHONE_MUSIC", Label: "Phone Music", CreatedOn: "2016-10-26T14:42:49.000+00:00", UpdatedOn: "2016-10-26T14:42:49.000+00:00"},
{ID: 34, Name: "ALEXA", Label: "Amazon Alexa", CreatedOn: "2017-12-04T19:18:47.000+00:00", UpdatedOn: "2017-12-04T19:18:47.000+00:00"},
{ID: 35, Name: "RADIOPLAYER", Label: "Radioplayer", CreatedOn: "2019-05-28T18:21:20.000+00:00", UpdatedOn: "2019-05-28T18:21:20.000+00:00"},
{ID: 36, Name: "RADIO.COM", Label: "Radio.com", CreatedOn: "2019-05-28T18:21:41.000+00:00", UpdatedOn: "2019-05-28T18:21:41.000+00:00"},
{ID: 37, Name: "RADIO_COM", Label: "Radio.com", CreatedOn: "2019-06-13T17:30:47.000+00:00", UpdatedOn: "2019-06-13T17:30:47.000+00:00"},
{ID: 38, Name: "SIRIUSXM_EVEREST", Label: "SiriusXM Everest", CreatedOn: "2019-11-25T18:00:33.000+00:00", UpdatedOn: "2019-11-25T18:00:33.000+00:00"},
{ID: 39, Name: "RADIO_BROWSER", Label: "Radio Browser", CreatedOn: "2026-03-14T22:47:00.000+00:00", UpdatedOn: "2026-03-14T22:47:00.000+00:00"},
}
// GetSourceLabel returns a user-friendly label for a source type.
func GetSourceLabel(sourceType string) string {
for _, provider := range StaticProviders {
if provider.Name == sourceType {
return provider.Label
}
}
switch sourceType {
case "BLUETOOTH":
return "Bluetooth"
case "BMX":
return "BMX"
case "NOTIFICATION":
return "Notifications"
case "TUNEIN":
return "TuneIn Radio"
case "SPOTIFY":
return "Spotify"
case "IHEART":
return "iHeartRadio"
case "AMAZON":
return "Amazon Music"
case "DEEZER":
return "Deezer"
case "SIRIUSXM":
return "SiriusXM"
case "TIDAL":
return "Tidal"
case "PANDORA":
return "Pandora"
case "AUX":
return "Aux"
case "AUX_IN":
return "AUX IN"
case "INTERNET_RADIO":
return "Internet Radio"
case "LOCAL_INTERNET_RADIO":
return "Local Internet Radio"
default:
return sourceType
}
}
// GetProviderName returns the human-readable name for a provider ID.
func GetProviderName(providerID string) string {
id, err := strconv.Atoi(providerID)
if err != nil {
return providerID
}
for _, p := range StaticProviders {
if p.ID == id {
return p.Name
}
}
return providerID
}
// Providers lists known source provider identifiers used by Bose SoundTouch.
var Providers = []string{
"PANDORA",
@@ -60,4 +174,7 @@ const (
// DateStr is the hardcoded date used in many Bose XML responses
DateStr = "2012-09-19T12:43:00.000+00:00"
// XMLHeader is the standard XML declaration for Bose SoundTouch responses
XMLHeader = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`
)
+393 -128
View File
@@ -24,11 +24,16 @@ func exists(path string) bool {
// DataStore represents the device and configuration storage.
type DataStore struct {
DataDir string
// DataDir is the (possibly relative) base directory for all datastore files.
DataDir string
// baseDir is the absolute, normalized base directory used for path safety checks.
baseDir string
eventMutex sync.RWMutex
deviceEvents map[string][]models.DeviceEvent
idMutex sync.RWMutex
deviceMappings map[string]string
fileMutex sync.RWMutex
}
// normalizeMAC normalizes a MAC address to a consistent format
@@ -53,28 +58,107 @@ func NewDataStore(dataDir string) *DataStore {
dataDir = "data"
}
absBase, err := filepath.Abs(dataDir)
if err != nil {
// Fallback to the provided dataDir if Abs fails; this preserves existing behavior.
absBase = dataDir
}
return &DataStore{
DataDir: dataDir,
baseDir: absBase,
deviceEvents: make(map[string][]models.DeviceEvent),
deviceMappings: make(map[string]string),
}
}
// safeJoin joins the given path elements to the datastore baseDir and ensures
// that the resulting absolute path stays within baseDir. If the check fails,
// baseDir is returned to prevent directory traversal.
func (ds *DataStore) safeJoin(elem ...string) string {
// Join the base directory with the provided elements.
path := filepath.Join(append([]string{ds.baseDir}, elem...)...)
absPath, err := filepath.Abs(path)
if err != nil {
// On error, fall back to baseDir to avoid using an unexpected path.
return ds.baseDir
}
base := ds.baseDir
if base == "" {
// If baseDir is not set for some reason, fall back to original path.
return absPath
}
// Ensure the resolved path is within the base directory.
baseWithSep := base
if !strings.HasSuffix(baseWithSep, string(os.PathSeparator)) {
baseWithSep += string(os.PathSeparator)
}
if absPath == base || strings.HasPrefix(absPath, baseWithSep) {
return absPath
}
// If the path would escape the base directory, return baseDir as a safe default.
return base
}
// SafeJoin returns a safe joined path relative to the datastore base directory.
func (ds *DataStore) SafeJoin(elem ...string) string {
return ds.safeJoin(elem...)
}
// ListAccounts returns a list of all account IDs (directories in the data root).
func (ds *DataStore) ListAccounts() ([]string, error) {
ds.fileMutex.RLock()
defer ds.fileMutex.RUnlock()
// Account data is stored in 'accounts' subdirectory within the data root.
accountsDir := filepath.Join(ds.baseDir, "accounts")
if !exists(accountsDir) {
return []string{"default"}, nil
}
entries, err := os.ReadDir(accountsDir)
if err != nil {
return nil, err
}
accounts := make([]string, 0)
for _, entry := range entries {
if entry.IsDir() {
// Basic filter to ignore common hidden/system dirs
if entry.Name() != ".git" && entry.Name() != "logs" {
accounts = append(accounts, entry.Name())
}
}
}
if len(accounts) == 0 {
accounts = append(accounts, "default")
}
return accounts, nil
}
// AccountDir returns the directory path for a specific account.
func (ds *DataStore) AccountDir(account string) string {
return filepath.Join(ds.DataDir, "accounts", account)
return ds.safeJoin("accounts", account)
}
// AccountDevicesDir returns the devices directory path for a specific account.
func (ds *DataStore) AccountDevicesDir(account string) string {
return filepath.Join(ds.AccountDir(account), constants.DevicesDir)
return ds.safeJoin("accounts", account, constants.DevicesDir)
}
// AccountDeviceDir returns the directory path for a specific device within an account.
func (ds *DataStore) AccountDeviceDir(account, device string) string {
// First, check if the device directory exists directly with the given deviceID
// This prioritizes MAC-based deviceIDs over legacy mappings
directPath := filepath.Join(ds.AccountDevicesDir(account), device)
directPath := ds.safeJoin("accounts", account, constants.DevicesDir, device)
if _, err := os.Stat(directPath); err == nil {
// Directory exists, use the direct deviceID (preferred for MAC-based IDs)
return directPath
@@ -94,7 +178,7 @@ func (ds *DataStore) AccountDeviceDir(account, device string) string {
if ok {
// Use the mapped device only if it exists and the direct path doesn't
mappedPath := filepath.Join(ds.AccountDevicesDir(account), mappedDevice)
mappedPath := ds.safeJoin("accounts", account, constants.DevicesDir, mappedDevice)
if _, err := os.Stat(mappedPath); err == nil {
return mappedPath
}
@@ -107,9 +191,17 @@ func (ds *DataStore) AccountDeviceDir(account, device string) string {
// GetDeviceInfo retrieves device information for the specified account and device.
func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDeviceInfo, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.DeviceInfoFile)
ds.fileMutex.RLock()
defer ds.fileMutex.RUnlock()
data, err := os.ReadFile(path)
return ds.getDeviceInfoNoLock(account, device)
}
func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.ServiceDeviceInfo, error) {
path := ds.AccountDeviceDir(account, device)
deviceInfoPath := filepath.Join(path, constants.DeviceInfoFile)
data, err := os.ReadFile(deviceInfoPath)
if err != nil {
return nil, err
}
@@ -146,6 +238,12 @@ func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDevic
}
for _, comp := range info.Components {
deviceInfo.Components = append(deviceInfo.Components, models.ServiceComponent{
Category: comp.Category,
SoftwareVersion: comp.SoftwareVersion,
SerialNumber: comp.SerialNumber,
})
switch comp.Category {
case "SCM":
deviceInfo.FirmwareVersion = comp.SoftwareVersion
@@ -196,9 +294,21 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
key = info.IPAddress
}
if !seenIDs[key] {
devices = append(devices, info)
seenIDs[key] = true
if !seenIDs[key] || info.Name != "" {
if seenIDs[key] && info.Name != "" {
// Replace previous empty-named entry with one that has a name
for j := range devices {
existing := &devices[j]
if (existing.DeviceID != "" && existing.DeviceID == info.DeviceID) ||
(existing.IPAddress != "" && existing.IPAddress == info.IPAddress) {
devices[j] = info
break
}
}
} else if !seenIDs[key] {
devices = append(devices, info)
seenIDs[key] = true
}
}
}
}
@@ -209,17 +319,72 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
func (ds *DataStore) getPossibleDataDirs() []string {
dirs := []string{}
if exists(filepath.Join(ds.DataDir, "accounts")) {
dirs = append(dirs, filepath.Join(ds.DataDir, "accounts"))
// Check primary data directory
if ds.DataDir != "" {
if exists(filepath.Join(ds.DataDir, "accounts")) {
dirs = append(dirs, filepath.Join(ds.DataDir, "accounts"))
}
// Also check the DataDir itself as a base for account directories
if exists(ds.DataDir) && ds.DataDir != "." {
dirs = append(dirs, ds.DataDir)
}
}
// Also check st-go/data/accounts if it's different and exists
altDir := "st-go/data/accounts"
if filepath.Join(ds.DataDir, "accounts") != altDir && exists(altDir) {
if exists(altDir) {
dirs = append(dirs, altDir)
}
// And st-go/data/accounts/default
altDir2 := "st-go/data"
if exists(altDir2) {
dirs = append(dirs, altDir2)
}
// And repro_data
altDir3 := "repro_data"
if exists(altDir3) {
dirs = append(dirs, altDir3)
}
return dirs
// Add special handling for test environments where we might have account directories
// directly in the current working directory or a temp dir.
// Walk up from DataDir to find any 'accounts' directory.
curr := ds.DataDir
for i := 0; i < 3; i++ {
absCurr, _ := filepath.Abs(curr)
if exists(filepath.Join(absCurr, "accounts")) {
dirs = append(dirs, filepath.Join(absCurr, "accounts"))
}
if exists(absCurr) {
dirs = append(dirs, absCurr)
}
if curr == "." || curr == "/" || curr == "" {
break
}
curr = filepath.Dir(curr)
}
// Remove duplicates and ensure unique directories
uniqueDirs := make(map[string]bool)
result := []string{}
for _, dir := range dirs {
absDir, err := filepath.Abs(dir)
if err != nil {
absDir = dir
}
if !uniqueDirs[absDir] {
uniqueDirs[absDir] = true
result = append(result, dir)
}
}
return result
}
func (ds *DataStore) listDevicesInAccount(baseDir, accountName string) []models.ServiceDeviceInfo {
@@ -323,6 +488,9 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
// GetPresets retrieves all presets for the specified account and device.
func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) {
ds.fileMutex.RLock()
defer ds.fileMutex.RUnlock()
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
data, err := os.ReadFile(path)
@@ -344,6 +512,7 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt"`
} `xml:"ContentItem"`
Source *models.ConfiguredSource `xml:"source"`
} `xml:"preset"`
}
@@ -355,19 +524,25 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
for i := range presetsWrap.Presets {
p := &presetsWrap.Presets[i]
cit := p.ContentItem.Type
presets = append(presets, models.ServicePreset{
ServiceContentItem: models.ServiceContentItem{
ID: p.ID,
Name: p.ContentItem.ItemName,
Source: p.ContentItem.Source,
Type: p.ContentItem.Type,
Location: p.ContentItem.Location,
SourceAccount: p.ContentItem.SourceAccount,
IsPresetable: p.ContentItem.IsPresetable,
Name: p.ContentItem.ItemName,
Source: p.ContentItem.Source,
Type: p.ContentItem.Type,
Location: p.ContentItem.Location,
SourceAccount: p.ContentItem.SourceAccount,
IsPresetable: p.ContentItem.IsPresetable,
ContentItemType: cit,
},
ID: p.ID,
ButtonNumber: p.ID,
ContainerArt: p.ContentItem.ContainerArt,
CreatedOn: p.CreatedOn,
UpdatedOn: p.UpdatedOn,
SourceConfig: p.Source,
})
}
@@ -376,7 +551,13 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
// SavePresets saves the preset list for the specified account and device.
func (ds *DataStore) SavePresets(account, device string, presets []models.ServicePreset) error {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
type PresetXML struct {
ID string `xml:"id,attr"`
@@ -386,11 +567,12 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
Source string `xml:"source,attr,omitempty"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr,omitempty"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt"`
} `xml:"ContentItem"`
Source *models.ConfiguredSource `xml:"source,omitempty"`
}
type PresetsXML struct {
@@ -405,7 +587,11 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
var pxml PresetXML
pxml.ID = p.ID
pxml.ID = p.ButtonNumber
if pxml.ID == "" {
pxml.ID = p.ID
}
pxml.CreatedOn = p.CreatedOn
pxml.UpdatedOn = p.UpdatedOn
pxml.ContentItem.Source = p.Source
@@ -415,6 +601,7 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
pxml.ContentItem.IsPresetable = "true"
pxml.ContentItem.ItemName = p.Name
pxml.ContentItem.ContainerArt = p.ContainerArt
pxml.Source = p.SourceConfig
px.Presets = append(px.Presets, pxml)
}
@@ -430,6 +617,9 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
// GetRecents retrieves all recent items for the specified account and device.
func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent, error) {
ds.fileMutex.RLock()
defer ds.fileMutex.RUnlock()
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
data, err := os.ReadFile(path)
@@ -437,57 +627,36 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
return nil, err
}
var recentsWrap struct {
Recents []struct {
ID string `xml:"id,attr"`
DeviceID string `xml:"deviceID,attr"`
UtcTime string `xml:"utcTime,attr"`
ContentItem struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt"`
} `xml:"contentItem"`
} `xml:"recent"`
type RecentsXML struct {
XMLName xml.Name `xml:"recents"`
Recents []models.ServiceRecent `xml:"recent"`
}
var recentsWrap RecentsXML
if err := xml.Unmarshal(data, &recentsWrap); err != nil {
return nil, fmt.Errorf("malformed recents XML at %s: %w", path, err)
}
recents := []models.ServiceRecent{}
recents := recentsWrap.Recents
maxID := 0
for i := range recentsWrap.Recents {
r := &recentsWrap.Recents[i]
for i := range recents {
r := &recents[i]
if id, err := strconv.Atoi(r.ID); err == nil {
if id > maxID {
maxID = id
}
}
recents = append(recents, models.ServiceRecent{
ServiceContentItem: models.ServiceContentItem{
ID: r.ID,
Name: r.ContentItem.ItemName,
Source: r.ContentItem.Source,
Type: r.ContentItem.Type,
Location: r.ContentItem.Location,
SourceAccount: r.ContentItem.SourceAccount,
IsPresetable: r.ContentItem.IsPresetable,
},
DeviceID: r.DeviceID,
UtcTime: r.UtcTime,
ContainerArt: r.ContentItem.ContainerArt,
})
}
// Ensure all recents have unique numeric IDs
for i := range recents {
r := &recents[i]
if r.ContentItemType == "" {
r.ContentItemType = r.Type
}
if _, err := strconv.Atoi(recents[i].ID); err != nil || recents[i].ID == "" {
maxID++
recents[i].ID = strconv.Itoa(maxID)
@@ -499,54 +668,26 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
// SaveRecents saves the recent items list for the specified account and device.
func (ds *DataStore) SaveRecents(account, device string, recents []models.ServiceRecent) error {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
type RecentXML struct {
ID string `xml:"id,attr"`
DeviceID string `xml:"deviceID,attr"`
UtcTime string `xml:"utcTime,attr"`
ContentItem struct {
Source string `xml:"source,attr,omitempty"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr,omitempty"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
ContainerArt string `xml:"containerArt"`
} `xml:"contentItem"`
dir := ds.AccountDeviceDir(account, device)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
path := filepath.Join(dir, constants.RecentsFile)
type RecentsXML struct {
XMLName xml.Name `xml:"recents"`
Recents []RecentXML `xml:"recent"`
XMLName xml.Name `xml:"recents"`
Recents []models.ServiceRecent `xml:"recent"`
}
var rx RecentsXML
for i := range recents {
r := &recents[i]
var rxml RecentXML
rxml.ID = r.ID
rxml.DeviceID = r.DeviceID
rxml.UtcTime = r.UtcTime
rxml.ContentItem.Source = r.Source
rxml.ContentItem.Type = r.Type
rxml.ContentItem.Location = r.Location
rxml.ContentItem.SourceAccount = r.SourceAccount
rxml.ContentItem.IsPresetable = r.IsPresetable
if rxml.ContentItem.IsPresetable == "" {
rxml.ContentItem.IsPresetable = "true"
}
rxml.ContentItem.ItemName = r.Name
rxml.ContentItem.ContainerArt = r.ContainerArt
rx.Recents = append(rx.Recents, rxml)
wrap := RecentsXML{
Recents: recents,
}
data, err := xml.MarshalIndent(rx, "", " ")
data, err := xml.MarshalIndent(wrap, "", " ")
if err != nil {
return err
}
@@ -558,10 +699,49 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
// SaveDeviceInfo saves device information for the specified account and device.
func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.ServiceDeviceInfo) error {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
if device == "" {
return fmt.Errorf("device ID/name cannot be empty")
}
// Try to load existing device info to avoid overwriting existing details with empty values.
existing, _ := ds.getDeviceInfoNoLock(account, device)
if existing != nil {
if info.Name == "" {
info.Name = existing.Name
}
if info.ProductCode == "" {
info.ProductCode = existing.ProductCode
}
if info.DeviceSerialNumber == "" {
info.DeviceSerialNumber = existing.DeviceSerialNumber
}
if info.ProductSerialNumber == "" {
info.ProductSerialNumber = existing.ProductSerialNumber
}
if info.FirmwareVersion == "" {
info.FirmwareVersion = existing.FirmwareVersion
}
if info.IPAddress == "" {
info.IPAddress = existing.IPAddress
}
if info.MacAddress == "" {
info.MacAddress = existing.MacAddress
}
if info.DiscoveryMethod == "" {
info.DiscoveryMethod = existing.DiscoveryMethod
}
}
dir := ds.AccountDeviceDir(account, device)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
@@ -607,11 +787,27 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
}
ix := InfoXML{
DeviceID: info.DeviceID,
Name: info.Name,
Type: devType,
ModuleType: moduleType,
Components: []ComponentXML{
DeviceID: info.DeviceID,
Name: info.Name,
Type: devType,
ModuleType: moduleType,
DiscoveryMethod: info.DiscoveryMethod,
}
if ix.DiscoveryMethod == "" {
ix.DiscoveryMethod = "sync_full"
}
for _, comp := range info.Components {
ix.Components = append(ix.Components, ComponentXML{
ComponentCategory: comp.Category,
SoftwareVersion: comp.SoftwareVersion,
SerialNumber: comp.SerialNumber,
})
}
if len(ix.Components) == 0 {
ix.Components = []ComponentXML{
{
ComponentCategory: "SCM",
SoftwareVersion: info.FirmwareVersion,
@@ -621,15 +817,15 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
ComponentCategory: "PackagedProduct",
SerialNumber: info.ProductSerialNumber,
},
}
}
ix.NetworkInfo = []NetworkInfoXML{
{
Type: "SCM",
IPAddress: info.IPAddress,
MacAddress: info.MacAddress,
},
NetworkInfo: []NetworkInfoXML{
{
Type: "SCM",
IPAddress: info.IPAddress,
MacAddress: info.MacAddress,
},
},
DiscoveryMethod: info.DiscoveryMethod,
}
data, err := xml.MarshalIndent(ix, "", " ")
@@ -642,9 +838,59 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
return os.WriteFile(path, append(header, data...), 0644)
}
// SaveAccountInfo saves account-level metadata to the datastore.
func (ds *DataStore) SaveAccountInfo(accountID string, info *models.ServiceAccountInfo) error {
if ds == nil || ds.DataDir == "" || accountID == "" {
return nil
}
dir := ds.AccountDir(accountID)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
path := filepath.Join(dir, "account.json")
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// GetAccountInfo retrieves account-level metadata from the datastore.
func (ds *DataStore) GetAccountInfo(accountID string) (*models.ServiceAccountInfo, error) {
if ds == nil || ds.DataDir == "" || accountID == "" {
return &models.ServiceAccountInfo{AccountID: accountID}, nil
}
// Try account root (canonical location)
path := filepath.Join(ds.AccountDir(accountID), "account.json")
if !exists(path) {
return &models.ServiceAccountInfo{AccountID: accountID, IsPlaceholder: true}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var info models.ServiceAccountInfo
if err := json.Unmarshal(data, &info); err != nil {
return nil, err
}
return &info, nil
}
// RemoveDevice removes a device and all its data from the specified account.
func (ds *DataStore) RemoveDevice(account, device string) error {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
dir := ds.AccountDeviceDir(account, device)
return os.RemoveAll(dir)
}
@@ -655,6 +901,9 @@ func (ds *DataStore) RemoveDeviceDir(account, device string) error {
// GetConfiguredSources retrieves all configured sources for the specified account and device.
func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.ConfiguredSource, error) {
ds.fileMutex.RLock()
defer ds.fileMutex.RUnlock()
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
data, err := os.ReadFile(path)
@@ -672,12 +921,24 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
for i := range sourcesWrap.Sources {
s := &sourcesWrap.Sources[i]
// Ensure SourceKey values are prioritized for legacy fields
if s.SourceKey.Type != "" {
s.SourceKeyType = s.SourceKey.Type
}
if s.SourceKey.Account != "" {
s.SourceKeyAccount = s.SourceKey.Account
}
// Ensure Type is populated from SourceKey if missing
if s.Type == "" && s.SourceKey.Type != "" {
s.Type = s.SourceKey.Type
}
if s.ID == "" {
s.ID = strconv.Itoa(100001 + i)
}
// Sync legacy fields
s.SourceKeyType = s.SourceKey.Type
s.SourceKeyAccount = s.SourceKey.Account
}
return sourcesWrap.Sources, nil
@@ -685,6 +946,9 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
// SaveConfiguredSources saves the configured sources list for the specified account and device.
func (ds *DataStore) SaveConfiguredSources(account, device string, sources []models.ConfiguredSource) error {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
@@ -875,21 +1139,22 @@ func (ds *DataStore) GetETagForAccount(account, device string) int64 {
// Settings represents the global service settings.
type Settings struct {
ServerURL string `json:"server_url"`
HTTPServerURL string `json:"https_server_url,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream []string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
PreferredSource string `json:"preferred_source,omitempty"`
InternalPaths []string `json:"internal_paths,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
ServerURL string `json:"server_url"`
HTTPServerURL string `json:"https_server_url,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream []string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
PreferredSource string `json:"preferred_source,omitempty"`
InternalPaths []string `json:"internal_paths,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
}
// GetSettings retrieves the global service settings.
+31
View File
@@ -92,6 +92,37 @@ func TestDataStore(t *testing.T) {
if ds.AccountDir(account) != expectedAccountDir {
t.Errorf("Expected account dir %s, got %s", expectedAccountDir, ds.AccountDir(account))
}
// Test GetAccountInfo with placeholder
accInfo, err := ds.GetAccountInfo("non-existent")
if err != nil {
t.Errorf("GetAccountInfo failed: %v", err)
}
if !accInfo.IsPlaceholder {
t.Errorf("Expected IsPlaceholder to be true for non-existent account")
}
// Test SaveAccountInfo and GetAccountInfo
accountID := "acc-123"
info2 := &models.ServiceAccountInfo{
AccountID: accountID,
PreferredLanguage: "en",
}
err = ds.SaveAccountInfo(accountID, info2)
if err != nil {
t.Errorf("SaveAccountInfo failed: %v", err)
}
loadedAccInfo, err := ds.GetAccountInfo(accountID)
if err != nil {
t.Errorf("GetAccountInfo failed: %v", err)
}
if loadedAccInfo.PreferredLanguage != "en" {
t.Errorf("Expected language en, got %s", loadedAccInfo.PreferredLanguage)
}
if loadedAccInfo.IsPlaceholder {
t.Errorf("Expected IsPlaceholder to be false for existing account")
}
}
func TestListAllDevices_Empty(t *testing.T) {
+64
View File
@@ -0,0 +1,64 @@
package datastore
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestSaveDeviceInfo_MergesName(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
account := "1234567"
device := "001122334455"
// 1. Initial save with name
info1 := &models.ServiceDeviceInfo{
DeviceID: device,
AccountID: account,
Name: "Living Room",
ProductCode: "SoundTouch 20",
}
if err := ds.SaveDeviceInfo(account, device, info1); err != nil {
t.Fatalf("First SaveDeviceInfo failed: %v", err)
}
// 2. Verify name is saved
saved1, err := ds.GetDeviceInfo(account, device)
if err != nil {
t.Fatalf("First GetDeviceInfo failed: %v", err)
}
if saved1.Name != "Living Room" {
t.Errorf("Expected name 'Living Room', got '%s'", saved1.Name)
}
// 3. Save with empty name (simulating power_on)
info2 := &models.ServiceDeviceInfo{
DeviceID: device,
AccountID: account,
Name: "",
ProductCode: "SoundTouch 20",
IPAddress: "192.168.1.100",
}
if err := ds.SaveDeviceInfo(account, device, info2); err != nil {
t.Fatalf("Second SaveDeviceInfo failed: %v", err)
}
// 4. Verify name is preserved
saved2, err := ds.GetDeviceInfo(account, device)
if err != nil {
t.Fatalf("Second GetDeviceInfo failed: %v", err)
}
if saved2.Name != "Living Room" {
t.Errorf("Expected name 'Living Room' to be preserved, but got '%s'", saved2.Name)
}
if saved2.IPAddress != "192.168.1.100" {
t.Errorf("Expected IPAddress '192.168.1.100', got '%s'", saved2.IPAddress)
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ func TestDataStore_MacAddressMapping(t *testing.T) {
// Test mapping resolution in AccountDeviceDir
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
expectedDir := filepath.Join("testdata/mapping", "accounts", accountID, "devices", serialNumber)
expectedDir, _ := filepath.Abs(filepath.Join("testdata/mapping", "accounts", accountID, "devices", serialNumber))
if resolvedDir != expectedDir {
t.Errorf("expected dir %s, got %s", expectedDir, resolvedDir)
}
@@ -0,0 +1,194 @@
package datastore
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestSavePresets_Format(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-format-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
account := "1234567"
device := "001122334455"
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
Name: "test-playlist",
Source: "SPOTIFY",
Type: "tracklisturl",
Location: "/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh",
SourceAccount: "test-user",
IsPresetable: "true",
},
ID: "1",
ButtonNumber: "1",
ContainerArt: "https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b",
CreatedOn: "1719128436",
UpdatedOn: "1728740382",
},
}
err = ds.SavePresets(account, device, presets)
if err != nil {
t.Fatalf("SavePresets failed: %v", err)
}
path := filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read Presets.xml: %v", err)
}
xmlContent := string(data)
// Check for correct id attribute
if !strings.Contains(xmlContent, `id="1"`) {
t.Errorf("Presets.xml missing correct id attribute, got: %s", xmlContent)
}
// Check that contentItemType is NOT present (as requested in previous issues)
if strings.Contains(xmlContent, "contentItemType") {
t.Errorf("Presets.xml should not contain contentItemType tag, got: %s", xmlContent)
}
// Verify unmarshaling still works
loadedPresets, err := ds.GetPresets(account, device)
if err != nil {
t.Fatalf("GetPresets failed: %v", err)
}
if len(loadedPresets) != 1 {
t.Fatalf("Expected 1 preset, got %d", len(loadedPresets))
}
if loadedPresets[0].ID != "1" {
t.Errorf("Expected ID 1, got %s", loadedPresets[0].ID)
}
if loadedPresets[0].ContentItemType != "tracklisturl" {
t.Errorf("Expected ContentItemType to be tracklisturl, got %s", loadedPresets[0].ContentItemType)
}
}
func TestSavePresets_PreservesID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-id-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
account := "test-acc"
device := "test-dev"
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
Name: "Preset 1",
},
ID: "1",
ButtonNumber: "1",
},
{
ServiceContentItem: models.ServiceContentItem{
Name: "Preset 2",
},
ID: "2",
// ButtonNumber is empty, should fall back to ID
},
{
ServiceContentItem: models.ServiceContentItem{
Name: "Preset 3",
},
ButtonNumber: "3",
// ID is empty, should use ButtonNumber
},
}
err = ds.SavePresets(account, device, presets)
if err != nil {
t.Fatalf("SavePresets failed: %v", err)
}
path := filepath.Join(ds.AccountDeviceDir(account, device), "Presets.xml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read Presets.xml: %v", err)
}
xmlContent := string(data)
if !strings.Contains(xmlContent, `id="1"`) {
t.Errorf("Expected id=\"1\", got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `id="2"`) {
t.Errorf("Expected id=\"2\", got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `id="3"`) {
t.Errorf("Expected id=\"3\", got: %s", xmlContent)
}
// Now check if GetPresets loads them correctly
loaded, err := ds.GetPresets(account, device)
if err != nil {
t.Fatalf("GetPresets failed: %v", err)
}
if len(loaded) != 3 {
t.Fatalf("Expected 3 presets, got %d", len(loaded))
}
for i, p := range loaded {
expectedID := strconv.Itoa(i + 1)
if p.ID != expectedID {
t.Errorf("At index %d, expected ID %s, got %s", i, expectedID, p.ID)
}
if p.ButtonNumber != expectedID {
t.Errorf("At index %d, expected ButtonNumber %s, got %s", i, expectedID, p.ButtonNumber)
}
}
}
func TestPresetsXML_NoID(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-noid-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
Name: "No ID Preset",
},
},
}
err = ds.SavePresets("acc", "dev", presets)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(ds.AccountDeviceDir("acc", "dev"), "Presets.xml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `id=""`) {
t.Errorf("Expected empty id attribute, got: %s", string(data))
}
}
@@ -0,0 +1,113 @@
package datastore
import (
"encoding/xml"
"os"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestSaveRecents_Format(t *testing.T) {
tempDir, err := os.MkdirTemp("", "datastore_recents_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
account := "test-account"
device := "test-device"
recents := []models.ServiceRecent{
{
ServiceContentItem: models.ServiceContentItem{
ID: "2567119953",
Name: "The National",
Source: "SPOTIFY",
Type: "tracklisturl",
Location: "/playback/container/c3BvdGlmeTp1c2VyOnRlc3QtdXNlcjpjb2xsZWN0aW9uOmFydGlzdDoyY0NVdEdLOXNEVTJFb0VsbmswR05C",
SourceAccount: "test-user",
IsPresetable: "true",
},
DeviceID: "001122334455",
UtcTime: "1771666755",
},
}
if err := ds.SaveRecents(account, device, recents); err != nil {
t.Fatalf("SaveRecents failed: %v", err)
}
path := filepath.Join(ds.AccountDeviceDir(account, device), "Recents.xml")
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read Recents.xml: %v", err)
}
expectedXML := `<?xml version="1.0" encoding="UTF-8"?>
<recents>
<recent deviceID="001122334455" utcTime="1771666755" id="2567119953">
<contentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTp1c2VyOnRlc3QtdXNlcjpjb2xsZWN0aW9uOmFydGlzdDoyY0NVdEdLOXNEVTJFb0VsbmswR05C" sourceAccount="test-user" isPresetable="true">
<itemName>The National</itemName>
</contentItem>
</recent>
</recents>`
// Normalize whitespace for comparison by unmarshaling both
var expected, actual struct {
XMLName xml.Name `xml:"recents"`
Recents []struct {
DeviceID string `xml:"deviceID,attr"`
UtcTime string `xml:"utcTime,attr"`
ID string `xml:"id,attr"`
ContentItem struct {
Source string `xml:"source,attr"`
Type string `xml:"type,attr"`
Location string `xml:"location,attr"`
SourceAccount string `xml:"sourceAccount,attr"`
IsPresetable string `xml:"isPresetable,attr"`
ItemName string `xml:"itemName"`
} `xml:"contentItem"`
} `xml:"recent"`
}
if err := xml.Unmarshal([]byte(expectedXML), &expected); err != nil {
t.Fatalf("Failed to unmarshal expected XML: %v", err)
}
if err := xml.Unmarshal(content, &actual); err != nil {
t.Fatalf("Failed to unmarshal actual XML: %v", err)
}
if len(actual.Recents) != 1 {
t.Fatalf("Expected 1 recent, got %d", len(actual.Recents))
}
r := actual.Recents[0]
if r.ID != "2567119953" || r.DeviceID != "001122334455" || r.UtcTime != "1771666755" {
t.Errorf("Attributes mismatch: %+v", r)
}
if r.ContentItem.ItemName != "The National" || r.ContentItem.Source != "SPOTIFY" {
t.Errorf("ContentItem mismatch: %+v", r.ContentItem)
}
if r.ContentItem.IsPresetable != "true" {
t.Errorf("IsPresetable mismatch: got %s, expected true", r.ContentItem.IsPresetable)
}
// Now test Round-trip (GetRecents)
loadedRecents, err := ds.GetRecents(account, device)
if err != nil {
t.Fatalf("GetRecents failed: %v", err)
}
if len(loadedRecents) != 1 {
t.Fatalf("Expected 1 loaded recent, got %d", len(loadedRecents))
}
lr := loadedRecents[0]
if lr.ID != "2567119953" || lr.Name != "The National" || lr.Source != "SPOTIFY" || lr.SourceAccount != "test-user" {
t.Errorf("Loaded recent mismatch: %+v", lr)
}
}
@@ -0,0 +1,96 @@
package datastore
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestSaveSources_Format(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-sources-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := NewDataStore(tempDir)
account := "1234567"
device := "001122334455"
sources := []models.ConfiguredSource{
{
DisplayName: "AUX IN",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "AUX", Account: "AUX"},
},
{
SecretType: "token",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "INTERNET_RADIO", Account: ""},
},
{
DisplayName: "user@example.com",
Secret: "dummy-token-spotify",
SecretType: "token_version_3",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "SPOTIFY", Account: "test-user"},
},
}
err = ds.SaveConfiguredSources(account, device, sources)
if err != nil {
t.Fatalf("SaveConfiguredSources failed: %v", err)
}
path := filepath.Join(ds.AccountDeviceDir(account, device), "Sources.xml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read Sources.xml: %v", err)
}
xmlContent := string(data)
// Check for correct attributes in first source
if !strings.Contains(xmlContent, `<source displayName="AUX IN" secret="" secretType="">`) {
t.Errorf("First source missing expected attributes. Got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `<sourceKey type="AUX" account="AUX" />`) &&
!strings.Contains(xmlContent, `<sourceKey type="AUX" account="AUX"></sourceKey>`) {
t.Errorf("First sourceKey incorrect. Got: %s", xmlContent)
}
// Check for third source (Spotify)
if !strings.Contains(xmlContent, `displayName="user@example.com"`) {
t.Errorf("Spotify source missing displayName. Got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `secretType="token_version_3"`) {
t.Errorf("Spotify source missing secretType. Got: %s", xmlContent)
}
if !strings.Contains(xmlContent, `<sourceKey type="SPOTIFY" account="test-user" />`) &&
!strings.Contains(xmlContent, `<sourceKey type="SPOTIFY" account="test-user"></sourceKey>`) {
t.Errorf("Spotify sourceKey incorrect. Got: %s", xmlContent)
}
// Negative checks for extra tags
if strings.Contains(xmlContent, "<sourcename>") {
t.Errorf("Sources.xml should not contain <sourcename> tag")
}
if strings.Contains(xmlContent, "<username>") {
t.Errorf("Sources.xml should not contain <username> tag")
}
if strings.Contains(xmlContent, "<name>") {
t.Errorf("Sources.xml should not contain <name> tag")
}
if strings.Contains(xmlContent, "<sourceSettings>") {
t.Errorf("Sources.xml should not contain <sourceSettings> tag")
}
}
+107
View File
@@ -0,0 +1,107 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestHandleBMXRegistry_DNSDependent(t *testing.T) {
tempDir, err := os.MkdirTemp("", "bmx-registry-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
localURL := "https://soundtouch.local"
server := NewServer(ds, nil, localURL, false, false, false)
t.Run("DNSEnabled_UsesBoseURL", func(t *testing.T) {
server.SetDNSSettings(true, "8.8.8.8", ":5353")
req := httptest.NewRequest("GET", "/bmx/v1/services", nil)
w := httptest.NewRecorder()
server.HandleBMXRegistry(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
services := resp["bmx_services"].([]interface{})
foundTuneIn := false
for _, s := range services {
service := s.(map[string]interface{})
if service["id"].(map[string]interface{})["name"] == "TUNEIN" {
foundTuneIn = true
baseURL := service["baseUrl"].(string)
if baseURL != "https://content.api.bose.io/bmx/tunein" {
t.Errorf("Expected baseUrl https://content.api.bose.io/bmx/tunein, got %s", baseURL)
}
// Check assets (MEDIA_SERVER) - should still be local
assets := service["assets"].(map[string]interface{})
icons := assets["icons"].(map[string]interface{})
for k, v := range icons {
iconURL := v.(string)
if strings.HasPrefix(iconURL, "{") {
t.Errorf("Icon %s still has placeholder: %s", k, iconURL)
}
if !strings.HasPrefix(iconURL, localURL+"/media/bmx-icons/tunein") {
t.Errorf("Icon %s should point to local media server bmx-icons subdirectory, got %s", k, iconURL)
}
}
}
}
if !foundTuneIn {
t.Error("TuneIn service not found in registry")
}
})
t.Run("DNSDisabled_UsesLocalURL", func(t *testing.T) {
server.SetDNSSettings(false, "", "")
req := httptest.NewRequest("GET", "/bmx/v1/services", nil)
w := httptest.NewRecorder()
server.HandleBMXRegistry(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
services := resp["bmx_services"].([]interface{})
foundTuneIn := false
for _, s := range services {
service := s.(map[string]interface{})
if service["id"].(map[string]interface{})["name"] == "TUNEIN" {
foundTuneIn = true
baseURL := service["baseUrl"].(string)
if baseURL != localURL+"/bmx/tunein" {
t.Errorf("Expected baseUrl %s/bmx/tunein, got %s", localURL, baseURL)
}
}
}
if !foundTuneIn {
t.Error("TuneIn service not found in registry")
}
})
}
@@ -1,425 +0,0 @@
package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
func TestComprehensiveMigration_MultipleExistingDevices(t *testing.T) {
// This test simulates the real-world scenario where a device has been discovered
// and saved under multiple identifiers over time, and now needs to be consolidated
// into a single MAC-based identifier.
tempDir, err := os.MkdirTemp("", "comprehensive-migration-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
accountID := "3230304"
// Scenario: Same device has been saved under different identifiers:
// 1. Initially discovered by IP address
// 2. Later discovered with UPnP serial
// 3. Later discovered with device component serial
// Create device entry #1: Saved by IP address (early discovery)
ipDeviceID := "192.168.1.100"
ipInfo := &models.ServiceDeviceInfo{
DeviceID: ipDeviceID,
AccountID: accountID,
Name: "Unknown Device", // Generic name from early discovery
IPAddress: ipDeviceID,
ProductCode: "Unknown",
FirmwareVersion: "0.0.0",
DiscoveryMethod: "UPnP",
}
if err := ds.SaveDeviceInfo(accountID, ipDeviceID, ipInfo); err != nil {
t.Fatalf("Failed to save IP-based device: %v", err)
}
// Save some presets for the IP-based device
testPresets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Source: "SPOTIFY",
Location: "spotify://playlist/test1",
Name: "Test Playlist 1",
},
CreatedOn: "2024-01-01T00:00:00Z",
UpdatedOn: "2024-01-01T00:00:00Z",
},
}
if err := ds.SavePresets(accountID, ipDeviceID, testPresets); err != nil {
t.Fatalf("Failed to save presets for IP device: %v", err)
}
// Create device entry #2: Saved by component serial (later discovery with better info)
serialDeviceID := "I6332527703739342000020"
serialInfo := &models.ServiceDeviceInfo{
DeviceID: serialDeviceID,
AccountID: accountID,
Name: "Sound Machinechen", // Real name from /info
IPAddress: "192.168.1.100", // Same IP as before
DeviceSerialNumber: serialDeviceID,
ProductCode: "SoundTouch 10",
FirmwareVersion: "27.0.6.46330.5043500",
ProductSerialNumber: "069231P63364828AE",
DiscoveryMethod: "UPnP",
}
if err := ds.SaveDeviceInfo(accountID, serialDeviceID, serialInfo); err != nil {
t.Fatalf("Failed to save serial-based device: %v", err)
}
// Save different presets for the serial-based device (user might have configured both thinking they're different)
serialPresets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
ID: "2",
Source: "SPOTIFY",
Location: "spotify://playlist/test2",
Name: "Test Playlist 2",
},
CreatedOn: "2024-01-02T00:00:00Z",
UpdatedOn: "2024-01-02T00:00:00Z",
},
}
if err := ds.SavePresets(accountID, serialDeviceID, serialPresets); err != nil {
t.Fatalf("Failed to save presets for serial device: %v", err)
}
// Create device entry #3: Saved by UPnP serial (yet another discovery)
upnpDeviceID := "UPnP789XYZ"
upnpInfo := &models.ServiceDeviceInfo{
DeviceID: upnpDeviceID,
AccountID: accountID,
Name: "SoundTouch Device", // Generic UPnP name
IPAddress: "192.168.1.100", // Same IP again
ProductCode: "SoundTouch 10 sm2",
FirmwareVersion: "27.0.6.46330.5043500", // Same firmware as serial device
DiscoveryMethod: "UPnP",
}
if err := ds.SaveDeviceInfo(accountID, upnpDeviceID, upnpInfo); err != nil {
t.Fatalf("Failed to save UPnP-based device: %v", err)
}
t.Logf("Test setup complete:")
t.Logf(" Device #1: %s (IP-based, early discovery)", ipDeviceID)
t.Logf(" Device #2: %s (serial-based, better info)", serialDeviceID)
t.Logf(" Device #3: %s (UPnP-based, latest discovery)", upnpDeviceID)
// Now simulate the device being rediscovered with /info endpoint working
deviceInfoXML := `<info deviceID="A81B6A536A98">
<name>Sound Machinechen</name>
<type>SoundTouch 10</type>
<margeAccountUUID>3230304</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
<serialNumber>I6332527703739342000020</serialNumber>
</component>
<component>
<componentCategory>PackagedProduct</componentCategory>
<softwareVersion>27.0.6.46330.5043500</softwareVersion>
<serialNumber>069231P63364828AE</serialNumber>
</component>
</components>
<margeURL>https://streaming.bose.com</margeURL>
<networkInfo type="SCM">
<macAddress>A81B6A536A98</macAddress>
<ipAddress>192.168.1.100</ipAddress>
</networkInfo>
<moduleType>sm2</moduleType>
</info>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/info" {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, deviceInfoXML)
} else {
http.NotFound(w, r)
}
}))
defer server.Close()
deviceIP := server.URL[len("http://"):]
sm := setup.NewManager(server.URL, ds, nil)
srv := NewServer(ds, sm, server.URL, false, false, false, false, false)
// Simulate device rediscovery
discoveredDevice := models.DiscoveredDevice{
Host: deviceIP,
Name: "Generic Discovery Name",
ModelID: "SoundTouch",
SerialNo: "UPnP789XYZ", // This should match one of the existing devices
DiscoveryMethod: "UPnP",
}
t.Logf("\nSimulating comprehensive device rediscovery...")
t.Logf(" Discovery IP: %s", deviceIP)
t.Logf(" Discovery Serial: %s", discoveredDevice.SerialNo)
// Handle discovered device - should find and migrate all existing variants
srv.handleDiscoveredDevice(discoveredDevice)
// Verify the device now exists under the MAC address
expectedDeviceID := "A81B6A536A98"
migratedInfo, err := ds.GetDeviceInfo(accountID, expectedDeviceID)
if err != nil {
t.Fatalf("Failed to get migrated device info: %v", err)
}
// Verify the migrated device has the correct information
if migratedInfo.DeviceID != expectedDeviceID {
t.Errorf("Expected deviceID '%s', got '%s'", expectedDeviceID, migratedInfo.DeviceID)
}
if migratedInfo.Name != "Sound Machinechen" {
t.Errorf("Expected name 'Sound Machinechen' (from /info), got '%s'", migratedInfo.Name)
}
if migratedInfo.MacAddress != "A81B6A536A98" {
t.Errorf("Expected MAC 'A81B6A536A98', got '%s'", migratedInfo.MacAddress)
}
if migratedInfo.DeviceSerialNumber != "I6332527703739342000020" {
t.Errorf("Expected device serial 'I6332527703739342000020', got '%s'", migratedInfo.DeviceSerialNumber)
}
t.Logf("\nMigration completed successfully:")
t.Logf(" New device ID: %s (MAC address)", migratedInfo.DeviceID)
t.Logf(" Device name: %s", migratedInfo.Name)
t.Logf(" Device serial: %s", migratedInfo.DeviceSerialNumber)
t.Logf(" Product serial: %s", migratedInfo.ProductSerialNumber)
t.Logf(" MAC address: %s", migratedInfo.MacAddress)
// Verify MAC address resolution works
if err := ds.Initialize(); err != nil {
t.Fatalf("Failed to initialize datastore: %v", err)
}
resolvedDir := ds.AccountDeviceDir(accountID, "A81B6A536A98")
expectedDir := ds.AccountDeviceDir(accountID, expectedDeviceID)
if resolvedDir != expectedDir {
t.Errorf("MAC resolution failed. Expected '%s', got '%s'", expectedDir, resolvedDir)
}
t.Logf("\nMAC address resolution verified:")
t.Logf(" MAC 'A81B6A536A98' resolves to correct device directory")
// Note: In a complete implementation, we'd also verify that presets from all
// the old devices were consolidated, but that requires more sophisticated
// preset merging logic which is beyond the current migration scope.
t.Logf("\n✅ Comprehensive migration test completed successfully!")
}
func TestFindAllExistingDeviceVariants_MatchingCriteria(t *testing.T) {
tempDir, err := os.MkdirTemp("", "variants-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
srv := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
accountID := "testaccount"
// Create devices that should match various criteria
devices := []models.ServiceDeviceInfo{
{
DeviceID: "192.168.1.100",
AccountID: accountID,
Name: "IP Device",
IPAddress: "192.168.1.100",
DeviceSerialNumber: "SERIAL123",
MacAddress: "AA:BB:CC:DD:EE:FF",
},
{
DeviceID: "SERIAL123",
AccountID: accountID,
Name: "Sound Speaker",
IPAddress: "192.168.1.101", // Different IP
DeviceSerialNumber: "SERIAL123",
MacAddress: "AA:BB:CC:DD:EE:FF",
},
{
DeviceID: "UPnPSerial456",
AccountID: accountID,
Name: "Sound Speaker",
IPAddress: "192.168.1.102", // Different IP again
ProductCode: "SoundTouch 10 sm2",
},
{
DeviceID: "UnrelatedDevice",
AccountID: accountID,
Name: "Other Device",
IPAddress: "192.168.1.200",
DeviceSerialNumber: "OTHERSSERIAL",
},
}
for _, device := range devices {
if err := ds.SaveDeviceInfo(accountID, device.DeviceID, &device); err != nil {
t.Fatalf("Failed to save device %s: %v", device.DeviceID, err)
}
}
// Create mock discovery and live info
discovery := models.DiscoveredDevice{
Host: "192.168.1.100", // Matches first device by IP
SerialNo: "UPnPSerial456", // Matches third device by UPnP serial
}
liveInfo := &setup.DeviceInfoXML{
DeviceID: "AABBCCDDEEFF", // New MAC-based ID
Name: "Sound Speaker", // Matches second and third devices by name
Type: "SoundTouch 10",
ModuleType: "sm2",
SerialNumber: "SERIAL123", // Matches first and second devices by serial
NetworkInfo: []struct {
Type string `xml:"type,attr"`
MacAddress string `xml:"macAddress"`
IPAddress string `xml:"ipAddress"`
}{
{Type: "SCM", MacAddress: "AA:BB:CC:DD:EE:FF", IPAddress: "192.168.1.100"},
},
}
// Test the matching logic
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
t.Logf("Found %d matching device variants:", len(matches))
for i, match := range matches {
t.Logf(" %d. %s (IP: %s, Serial: %s, MAC: %s, Name: %s)",
i+1, match.DeviceID, match.IPAddress, match.DeviceSerialNumber, match.MacAddress, match.Name)
}
// Verify expected matches
expectedMatches := map[string]string{
"192.168.1.100": "IP match",
"SERIAL123": "Serial match",
"UPnPSerial456": "UPnP serial match",
}
if len(matches) != len(expectedMatches) {
t.Errorf("Expected %d matches, got %d", len(expectedMatches), len(matches))
}
foundMatches := make(map[string]bool)
for _, match := range matches {
foundMatches[match.DeviceID] = true
}
for expectedID, reason := range expectedMatches {
if !foundMatches[expectedID] {
t.Errorf("Expected to find device %s (%s), but it was not matched", expectedID, reason)
}
}
// Verify UnrelatedDevice is NOT matched
if foundMatches["UnrelatedDevice"] {
t.Error("UnrelatedDevice should not have been matched, but it was")
}
t.Logf("✅ Device variant matching test completed successfully!")
}
func TestMigration_EdgeCases(t *testing.T) {
tempDir, err := os.MkdirTemp("", "migration-edge-cases-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
srv := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
accountID := "testaccount"
t.Run("NoExistingDevices", func(t *testing.T) {
discovery := models.DiscoveredDevice{Host: "192.168.1.200"}
liveInfo := &setup.DeviceInfoXML{DeviceID: "NEWMAC123"}
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
if len(matches) != 0 {
t.Errorf("Expected 0 matches for new device, got %d", len(matches))
}
})
t.Run("SelfMatch", func(t *testing.T) {
// Device already exists with MAC as deviceID
macDeviceID := "AABBCCDDEEFF"
existing := &models.ServiceDeviceInfo{
DeviceID: macDeviceID,
AccountID: accountID,
Name: "Existing MAC Device",
IPAddress: "192.168.1.150",
}
if err := ds.SaveDeviceInfo(accountID, macDeviceID, existing); err != nil {
t.Fatalf("Failed to save MAC device: %v", err)
}
discovery := models.DiscoveredDevice{Host: "192.168.1.150"}
liveInfo := &setup.DeviceInfoXML{DeviceID: macDeviceID} // Same MAC
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
// Should find itself, but migration logic should skip it since deviceID matches
found := false
for _, match := range matches {
if match.DeviceID == macDeviceID {
found = true
break
}
}
if !found {
t.Error("Device should find itself in variants")
}
})
t.Run("PartialMatches", func(t *testing.T) {
// Device with some matching criteria but not others
partialDevice := &models.ServiceDeviceInfo{
DeviceID: "PARTIAL123",
AccountID: accountID,
Name: "Partial Device",
IPAddress: "192.168.1.160", // Different IP
// No serial number, no MAC
}
if err := ds.SaveDeviceInfo(accountID, "PARTIAL123", partialDevice); err != nil {
t.Fatalf("Failed to save partial device: %v", err)
}
discovery := models.DiscoveredDevice{Host: "192.168.1.170"} // Different IP
liveInfo := &setup.DeviceInfoXML{
DeviceID: "NEWMAC456",
Name: "Partial Device", // Same name
Type: "SoundTouch 20",
}
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
// Should match by name and product type
found := false
for _, match := range matches {
if match.DeviceID == "PARTIAL123" {
found = true
break
}
}
if !found {
t.Error("Should match device by name and product type")
}
})
}
-254
View File
@@ -1,254 +0,0 @@
package handlers
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestDeviceMigration_DirectoryRename(t *testing.T) {
tempDir, err := os.MkdirTemp("", "migration-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
srv := NewServer(ds, nil, "http://localhost", false, false, false, true, false)
accountID := "test-account"
macAddress := "A81B6A536A98"
serialNumber := "I6332527703739342000020"
// Create serial-based device entry with full data (simulates legacy directory)
serialDeviceInfo := &models.ServiceDeviceInfo{
DeviceID: serialNumber,
AccountID: accountID,
Name: "Living Room Speaker",
MacAddress: macAddress,
DeviceSerialNumber: serialNumber,
ProductCode: "SoundTouch 30",
}
if err := ds.SaveDeviceInfo(accountID, serialNumber, serialDeviceInfo); err != nil {
t.Fatalf("Failed to save serial-based device: %v", err)
}
// Create some preset data in the serial-based directory
serialPresets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "My Preset",
Source: "SPOTIFY",
},
},
}
if err := ds.SavePresets(accountID, serialNumber, serialPresets); err != nil {
t.Fatalf("Failed to save presets: %v", err)
}
// Verify initial state - serial directory exists
serialDir := ds.AccountDeviceDir(accountID, serialNumber)
if _, err := os.Stat(serialDir); os.IsNotExist(err) {
t.Fatalf("Serial directory should exist before migration: %s", serialDir)
}
// Perform migration using migration manager
existingDevices := []models.ServiceDeviceInfo{*serialDeviceInfo}
srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress)
// Verify migration results
t.Run("VerifyMigration", func(t *testing.T) {
// 1. MAC directory should exist with files
macDir := ds.AccountDeviceDir(accountID, macAddress)
serialDir := ds.AccountDeviceDir(accountID, serialNumber)
if _, err := os.Stat(macDir); os.IsNotExist(err) {
t.Errorf("MAC directory should exist after migration: %s", macDir)
}
// 2. Serial directory should be gone
if _, err := os.Stat(serialDir); !os.IsNotExist(err) {
t.Errorf("Serial directory should not exist after migration: %s", serialDir)
}
// 3. Simulate SaveDeviceInfo with fresh data (like real discovery flow)
// This overwrites DeviceInfo.xml with correct MAC-based deviceID
freshDeviceInfo := &models.ServiceDeviceInfo{
DeviceID: macAddress,
AccountID: accountID,
Name: "Sound Speaker Fresh",
IPAddress: "192.168.1.100",
MacAddress: macAddress,
DeviceSerialNumber: serialNumber,
ProductCode: "SoundTouch 10 sm2",
FirmwareVersion: "3.4.6.2356",
ProductSerialNumber: "069231P63364828AE",
DiscoveryMethod: "Migration Test",
}
if err := ds.SaveDeviceInfo(accountID, macAddress, freshDeviceInfo); err != nil {
t.Errorf("Failed to save fresh device info: %v", err)
}
// 4. Device info should now have correct MAC-based deviceID
macInfo, err := ds.GetDeviceInfo(accountID, macAddress)
if err != nil {
t.Errorf("Should be able to get device info with MAC ID: %v", err)
} else if macInfo.DeviceID != macAddress {
t.Errorf("DeviceID should be updated to MAC address, got %s", macInfo.DeviceID)
}
// 5. All data should be accessible through MAC address
presets, err := ds.GetPresets(accountID, macAddress)
if err != nil {
t.Errorf("Should be able to get presets through MAC address: %v", err)
} else if len(presets) != 1 || presets[0].Name != "My Preset" {
t.Errorf("Presets should be preserved during migration")
}
t.Logf("✓ Device directory migration working correctly")
})
}
func TestDeviceMigration_NoExistingTarget(t *testing.T) {
tempDir, err := os.MkdirTemp("", "no-target-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
srv := NewServer(ds, nil, "http://localhost", false, false, false, true, false)
accountID := "test-account"
macAddress := "A81B6A536A98"
serialNumber := "I6332527703739342000020"
// Create only serial-based device entry (no existing MAC directory)
serialDeviceInfo := &models.ServiceDeviceInfo{
DeviceID: serialNumber,
AccountID: accountID,
Name: "Test Speaker",
MacAddress: macAddress,
DeviceSerialNumber: serialNumber,
ProductCode: "SoundTouch 30",
}
if err := ds.SaveDeviceInfo(accountID, serialNumber, serialDeviceInfo); err != nil {
t.Fatalf("Failed to save serial device: %v", err)
}
// Add some data files
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "Test Preset",
Source: "SPOTIFY",
},
},
}
if err := ds.SavePresets(accountID, serialNumber, presets); err != nil {
t.Fatalf("Failed to save presets: %v", err)
}
// Verify MAC directory doesn't exist initially
macDir := ds.AccountDeviceDir(accountID, macAddress)
if _, err := os.Stat(macDir); !os.IsNotExist(err) {
t.Fatalf("MAC directory should not exist initially: %s", macDir)
}
// Migrate directory using migration manager
existingDevices := []models.ServiceDeviceInfo{*serialDeviceInfo}
srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress)
// Verify migration
if _, err := os.Stat(macDir); os.IsNotExist(err) {
t.Errorf("MAC directory should exist after migration: %s", macDir)
}
// Verify data is accessible
migratedPresets, err := ds.GetPresets(accountID, macAddress)
if err != nil {
t.Errorf("Should be able to access presets after migration: %v", err)
} else if len(migratedPresets) != 1 || migratedPresets[0].Name != "Test Preset" {
t.Errorf("Presets should be preserved in migration")
}
t.Log("✓ Simple directory migration working correctly")
}
func TestDeviceMigration_ExistingTargetRemoved(t *testing.T) {
tempDir, err := os.MkdirTemp("", "existing-target-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
srv := NewServer(ds, nil, "http://localhost", false, false, false, true, false)
accountID := "test-account"
macAddress := "A81B6A536A98"
serialNumber := "I6332527703739342000020"
// Create both directories (serial has rich data, MAC has minimal data)
serialInfo := &models.ServiceDeviceInfo{
DeviceID: serialNumber,
AccountID: accountID,
Name: "Rich Data Device",
MacAddress: macAddress,
DeviceSerialNumber: serialNumber,
}
macInfo := &models.ServiceDeviceInfo{
DeviceID: macAddress,
AccountID: accountID,
Name: "Minimal Data Device",
MacAddress: macAddress,
}
if err := ds.SaveDeviceInfo(accountID, serialNumber, serialInfo); err != nil {
t.Fatalf("Failed to save serial device: %v", err)
}
if err := ds.SaveDeviceInfo(accountID, macAddress, macInfo); err != nil {
t.Fatalf("Failed to save MAC device: %v", err)
}
// Add rich data to serial directory
richPresets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "Rich Preset",
Source: "SPOTIFY",
},
},
}
if err := ds.SavePresets(accountID, serialNumber, richPresets); err != nil {
t.Fatalf("Failed to save rich presets: %v", err)
}
// Migrate - should replace MAC directory with serial directory content
existingDevices := []models.ServiceDeviceInfo{*serialInfo}
srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress)
// Verify the rich data is now accessible via MAC address
finalInfo, err := ds.GetDeviceInfo(accountID, macAddress)
if err != nil {
t.Errorf("Should be able to get device info after migration: %v", err)
} else if finalInfo.Name != "Rich Data Device" {
t.Errorf("Should have rich device data, got name: %s", finalInfo.Name)
}
finalPresets, err := ds.GetPresets(accountID, macAddress)
if err != nil {
t.Errorf("Should be able to get rich presets after migration: %v", err)
} else if len(finalPresets) != 1 || finalPresets[0].Name != "Rich Preset" {
t.Errorf("Should have rich presets after migration")
}
t.Log("✓ Migration correctly replaces existing target with richer source")
}
@@ -0,0 +1,388 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/go-chi/chi/v5"
)
// HandleMgmtAccountDetails returns full details for an account for the Web UI.
func (s *Server) HandleMgmtAccountDetails(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountId")
// 1. Get account info
accountInfo, err := s.ds.GetAccountInfo(accountID)
if err != nil {
log.Printf("[Mgmt] Failed to get account info for %s: %v", accountID, err)
accountInfo = &models.ServiceAccountInfo{AccountID: accountID}
}
// Enrich provider settings with names
for i := range accountInfo.ProviderSettings {
s := &accountInfo.ProviderSettings[i]
if s.ProviderName == "" {
s.ProviderName = constants.GetProviderName(s.ProviderID)
}
}
// 2. List all devices for this account
allDevices, err := s.ds.ListAllDevices()
if err != nil {
log.Printf("[Mgmt] Failed to list devices: %v", err)
}
accountDevices := make([]deviceDetail, 0)
for i := range allDevices {
d := &allDevices[i]
if d.AccountID != accountID {
continue
}
detail := s.getDeviceDetail(accountID, d)
accountDevices = append(accountDevices, detail)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"account": accountInfo,
"devices": accountDevices,
}); err != nil {
log.Printf("[Mgmt] Failed to encode account details: %v", err)
}
}
// HandleMgmtUpdateAccountLanguage updates the preferred language for an account.
func (s *Server) HandleMgmtUpdateAccountLanguage(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountId")
var req struct {
Language string `json:"language"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Language != "en" && req.Language != "de" {
http.Error(w, "Language must be 'en' or 'de'", http.StatusBadRequest)
return
}
// 1. Load current account info
accountInfo, err := s.ds.GetAccountInfo(accountID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 2. Update language
accountInfo.AccountID = accountID // Ensure ID is correct
accountInfo.PreferredLanguage = req.Language
accountInfo.IsPlaceholder = false
// 3. Save account info
if err := s.ds.SaveAccountInfo(accountID, accountInfo); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// HandleMgmtUpdateAccountProviderSetting updates a specific provider setting for an account.
func (s *Server) HandleMgmtUpdateAccountProviderSetting(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountId")
var req struct {
ProviderID string `json:"provider_id"`
Key string `json:"key"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.ProviderID == "" || req.Key == "" {
http.Error(w, "provider_id and key are required", http.StatusBadRequest)
return
}
// 1. Load current account info
accountInfo, err := s.ds.GetAccountInfo(accountID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 2. Update the specific setting
found := false
for i, setting := range accountInfo.ProviderSettings {
if setting.ProviderID == req.ProviderID && setting.KeyName == req.Key {
accountInfo.ProviderSettings[i].Value = req.Value
found = true
break
}
}
if !found {
// If not found, we can choose to add it or return error.
// For now, let's return an error as we expect to edit existing ones.
http.Error(w, "Provider setting not found", http.StatusNotFound)
return
}
accountInfo.AccountID = accountID // Ensure ID is correct
accountInfo.IsPlaceholder = false
// 3. Save account info
if err := s.ds.SaveAccountInfo(accountID, accountInfo); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
type deviceDetail struct {
models.AccountDevice
Presets []models.FullResponsePreset `json:"presets,omitempty"`
Recents []models.FullResponseRecent `json:"recents,omitempty"`
Sources []models.FullResponseSource `json:"sources,omitempty"`
Components []models.ServiceComponent `json:"components,omitempty"`
}
func (s *Server) getDeviceDetail(accountID string, d *models.ServiceDeviceInfo) deviceDetail {
detail := deviceDetail{
AccountDevice: models.AccountDevice{
DeviceID: d.DeviceID,
FirmwareVersion: d.FirmwareVersion,
IPAddress: d.IPAddress,
Name: d.Name,
ProductCode: d.ProductCode,
SerialNumber: d.DeviceSerialNumber,
DeviceSerialNumber: d.DeviceSerialNumber,
MacAddress: d.MacAddress,
DiscoveryMethod: d.DiscoveryMethod,
},
}
// We also have AttachedProduct which has Components
detail.AttachedProduct = &models.AttachedProduct{
SerialNumber: d.DeviceSerialNumber,
ProductCode: d.ProductCode,
ProductLabel: d.Name,
Components: d.Components,
}
detail.Components = d.Components
// Fetch sources
var configuredSources []models.ConfiguredSource
if sources, err := s.ds.GetConfiguredSources(accountID, d.DeviceID); err == nil {
configuredSources = sources
for j := range sources {
fs := mapToFullResponseSource(&sources[j])
if fs.Type == "" && fs.Name == "" && fs.DisplayName == "" {
log.Printf("[Mgmt] Skipping empty source for device %s", d.DeviceID)
continue
}
detail.Sources = append(detail.Sources, fs)
}
}
// Fetch presets
if presets, err := s.ds.GetPresets(accountID, d.DeviceID); err == nil {
for j := range presets {
detail.Presets = append(detail.Presets, mapToFullResponsePreset(&presets[j], configuredSources))
}
}
detail.AccountDevice.Presets = detail.Presets
// Fetch recents
if recents, err := s.ds.GetRecents(accountID, d.DeviceID); err == nil {
for j := range recents {
detail.Recents = append(detail.Recents, mapToFullResponseRecent(&recents[j], configuredSources))
}
}
detail.AccountDevice.Recents = detail.Recents
return detail
}
func mapToFullResponseSource(src *models.ConfiguredSource) models.FullResponseSource {
fs := models.FullResponseSource{
ID: src.ID,
Type: src.Type,
DisplayName: src.DisplayName,
Name: src.DisplayName,
Username: src.Username,
SourceName: src.SourceName,
SourceProviderID: src.SourceProviderID,
CreatedOn: src.CreatedOn,
UpdatedOn: src.UpdatedOn,
Account: src.SourceKey.Account,
SourceLabel: constants.GetSourceLabel(src.Type),
SourceSettings: src.SourceSettings,
}
fs.Credential.Value = src.Secret
fs.Credential.Type = src.SecretType
// If DisplayName is generic (e.g. "Audio") and we have a more specific Account name, use it.
if fs.DisplayName == fs.Type && fs.Account != "" {
fs.DisplayName = fs.Account
fs.Name = fs.Account
}
// Provide fallback for Name and SourceName if missing
switch {
case fs.Name != "":
// Name already set to DisplayName
case fs.Account != "":
fs.Name = fs.Account
case fs.SourceLabel != "":
fs.Name = fs.SourceLabel
default:
fs.Name = fs.Type
}
if fs.SourceName == "" {
fs.SourceName = fs.Name
}
if fs.DisplayName == "" {
fs.DisplayName = fs.Name
}
return fs
}
func mapToFullResponsePreset(p *models.ServicePreset, configuredSources []models.ConfiguredSource) models.FullResponsePreset {
fp := models.FullResponsePreset{
ButtonNumber: p.ButtonNumber,
ContainerArt: p.ContainerArt,
ContentItemType: p.ContentItemType,
CreatedOn: p.CreatedOn,
Location: p.Location,
Name: p.Name,
UpdatedOn: p.UpdatedOn,
}
if fp.Name == "" {
fp.Name = p.Name
}
if fp.CreatedOn == "" && p.CreatedOn != "" {
fp.CreatedOn = p.CreatedOn
}
if p.SourceConfig != nil {
fp.Source = mapToFullResponseSource(p.SourceConfig)
} else {
// Attempt to find matching source in configuredSources
found := false
for k := range configuredSources {
src := &configuredSources[k]
if src.SourceKey.Type == p.Source && (src.SourceKey.Account == p.SourceAccount || p.SourceAccount == "") {
fp.Source = mapToFullResponseSource(src)
found = true
break
}
}
if !found && p.Source != "" {
// Create a dummy source for UI purposes if not found in configured sources
dummy := &models.ConfiguredSource{
Type: p.Source,
}
dummy.SourceKey.Type = p.Source
dummy.SourceKey.Account = p.SourceAccount
fp.Source = mapToFullResponseSource(dummy)
}
}
return fp
}
func mapToFullResponseRecent(r *models.ServiceRecent, configuredSources []models.ConfiguredSource) models.FullResponseRecent {
fr := models.FullResponseRecent{
ID: r.ID,
ContentItemType: r.ContentItemType,
CreatedOn: r.CreatedOn,
LastPlayedAt: r.LastPlayedAt,
Location: r.Location,
Name: r.Name,
SourceID: r.SourceID,
UpdatedOn: r.UpdatedOn,
}
if fr.Name == "" {
fr.Name = r.Name
}
if fr.CreatedOn == "" && r.CreatedOn != "" {
fr.CreatedOn = r.CreatedOn
} else if fr.CreatedOn == "" && r.UtcTime != "" {
fr.CreatedOn = r.UtcTime
}
if r.SourceConfig != nil {
fr.Source = mapToFullResponseSource(r.SourceConfig)
} else {
// Attempt to find matching source in configuredSources
found := false
for k := range configuredSources {
src := &configuredSources[k]
if src.SourceKey.Type == r.Source && (src.SourceKey.Account == r.SourceAccount || r.SourceAccount == "") {
fr.Source = mapToFullResponseSource(src)
found = true
break
}
}
if !found && r.Source != "" {
// Create a dummy source for UI purposes if not found in configured sources
dummy := &models.ConfiguredSource{
Type: r.Source,
}
dummy.SourceKey.Type = r.Source
dummy.SourceKey.Account = r.SourceAccount
fr.Source = mapToFullResponseSource(dummy)
}
}
return fr
}
// HandleMgmtListAccounts returns a list of all account IDs in the datastore.
func (s *Server) HandleMgmtListAccounts(w http.ResponseWriter, _ *http.Request) {
accounts, err := s.ds.ListAccounts()
if err != nil {
log.Printf("[Mgmt] Failed to list accounts: %v", err)
accounts = []string{"default"}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"accounts": accounts,
}); err != nil {
log.Printf("[Mgmt] Failed to encode accounts: %v", err)
}
}
@@ -0,0 +1,425 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/go-chi/chi/v5"
)
func TestHandleMgmtAccountDetails_Recents(t *testing.T) {
tempBaseDir := "mgmt_test_data"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
ds := datastore.NewDataStore(tempBaseDir)
err = ds.Initialize()
if err != nil {
t.Fatal(err)
}
accountID := "1234567"
deviceID := "001122334455"
// Setup a device with a recent item that has utcTime and name in ContentItem
deviceDir := ds.AccountDeviceDir(accountID, deviceID)
err = os.MkdirAll(deviceDir, 0755)
if err != nil {
t.Fatal(err)
}
recentsXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<recents>
<recent id="2538285498" utcTime="1690000000">
<contentItem source="INTERNET_RADIO" type="stationurl">
<itemName>For Your Darkest Days</itemName>
</contentItem>
</recent>
</recents>`
err = os.WriteFile(deviceDir+"/Recents.xml", []byte(recentsXML), 0644)
if err != nil {
t.Fatal(err)
}
// Also need a device info file to be listed
deviceInfo := models.ServiceDeviceInfo{
AccountID: accountID,
DeviceID: deviceID,
Name: "Test Device",
}
err = ds.SaveDeviceInfo(accountID, deviceID, &deviceInfo)
if err != nil {
t.Fatal(err)
}
server := &Server{ds: ds}
r := chi.NewRouter()
r.Get("/mgmt/accounts/{accountId}", server.HandleMgmtAccountDetails)
req := httptest.NewRequest("GET", "/mgmt/accounts/1234567", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response struct {
Devices []struct {
Recents []models.FullResponseRecent `json:"recents"`
} `json:"devices"`
}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if len(response.Devices) == 0 {
t.Fatal("Expected at least one device")
}
recents := response.Devices[0].Recents
if len(recents) == 0 {
t.Fatal("Expected one recent item")
}
r0 := recents[0]
if r0.Name != "For Your Darkest Days" {
t.Errorf("Expected recent name 'For Your Darkest Days', got '%s'", r0.Name)
}
if r0.CreatedOn != "1690000000" {
t.Errorf("Expected recent created_on '1690000000' (from utcTime), got '%s'", r0.CreatedOn)
}
// Test Preset mapping as well
presetsXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<presets>
<preset id="1" createdOn="1690000001">
<ContentItem source="SPOTIFY" type="tracklisturl" sourceAccount="test-user">
<itemName>test-playlist</itemName>
</ContentItem>
</preset>
</presets>`
err = os.WriteFile(deviceDir+"/Presets.xml", []byte(presetsXML), 0644)
if err != nil {
t.Fatal(err)
}
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req)
var response2 struct {
Devices []struct {
Presets []models.FullResponsePreset `json:"presets"`
} `json:"devices"`
}
err = json.Unmarshal(w2.Body.Bytes(), &response2)
if err != nil {
t.Fatal(err)
}
if len(response2.Devices[0].Presets) == 0 {
t.Fatal("Expected one preset")
}
p0 := response2.Devices[0].Presets[0]
if p0.Name != "test-playlist" {
t.Errorf("Expected preset name 'test-playlist', got '%s'", p0.Name)
}
if p0.CreatedOn != "1690000001" {
t.Errorf("Expected preset created_on '1690000001', got '%s'", p0.CreatedOn)
}
// Verify ButtonNumber/ID handling
if p0.ButtonNumber != "1" {
t.Errorf("Expected button_number '1', got '%s'", p0.ButtonNumber)
}
}
func TestHandleMgmtUpdateAccountLanguage(t *testing.T) {
tempBaseDir := "mgmt_test_data_lang"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
ds := datastore.NewDataStore(tempBaseDir)
err = ds.Initialize()
if err != nil {
t.Fatal(err)
}
accountID := "1234567"
server := &Server{ds: ds}
r := chi.NewRouter()
r.Post("/mgmt/accounts/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
t.Run("Valid Language de", func(t *testing.T) {
body, _ := json.Marshal(map[string]string{"language": "de"})
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/language", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
accInfo, _ := ds.GetAccountInfo(accountID)
if accInfo.PreferredLanguage != "de" {
t.Errorf("Expected language 'de', got '%s'", accInfo.PreferredLanguage)
}
})
t.Run("Invalid Language fr", func(t *testing.T) {
body, _ := json.Marshal(map[string]string{"language": "fr"})
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/language", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
})
}
func TestHandleMgmtUpdateAccountProviderSetting(t *testing.T) {
tempBaseDir := "mgmt_test_data_provider"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
ds := datastore.NewDataStore(tempBaseDir)
err = ds.Initialize()
if err != nil {
t.Fatal(err)
}
accountID := "1234567"
server := &Server{ds: ds}
// Setup initial account info
initialInfo := &models.ServiceAccountInfo{
AccountID: accountID,
ProviderSettings: []models.ProviderSetting{
{
ProviderID: "15",
KeyName: "STREAMING_QUALITY",
Value: "2",
},
},
}
ds.SaveAccountInfo(accountID, initialInfo)
r := chi.NewRouter()
r.Post("/mgmt/accounts/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
t.Run("Valid Update", func(t *testing.T) {
payload := map[string]string{
"provider_id": "15",
"key": "STREAMING_QUALITY",
"value": "3",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/provider-settings", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
accInfo, _ := ds.GetAccountInfo(accountID)
found := false
for _, s := range accInfo.ProviderSettings {
if s.ProviderID == "15" && s.KeyName == "STREAMING_QUALITY" {
if s.Value != "3" {
t.Errorf("Expected value '3', got '%s'", s.Value)
}
found = true
}
}
if !found {
t.Error("Provider setting not found after update")
}
})
t.Run("Setting Not Found", func(t *testing.T) {
payload := map[string]string{
"provider_id": "99",
"key": "NON_EXISTENT",
"value": "val",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/mgmt/accounts/1234567/provider-settings", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", w.Code)
}
})
}
func TestHandleMgmtAccountDetails_Sources(t *testing.T) {
tempBaseDir := "sources_test_data"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
ds := datastore.NewDataStore(tempBaseDir)
err = ds.Initialize()
if err != nil {
t.Fatal(err)
}
accountID := "1234567"
deviceID := "001122334455"
deviceDir := ds.AccountDeviceDir(accountID, deviceID)
err = os.MkdirAll(deviceDir, 0755)
if err != nil {
t.Fatal(err)
}
// Mock Sources.xml as it might be read by Sync/Save logic if we were using it,
// but here we will save them directly via DataStore.
sources := []models.ConfiguredSource{
{
ID: "9330201",
Type: "Audio",
DisplayName: "Audio",
SourceName: "Audio",
Name: "Audio",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{
Type: "Audio",
},
},
{
ID: "10863533",
Type: "Audio",
DisplayName: "Audio",
SourceName: "Audio",
Name: "Audio",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{
Type: "Audio",
Account: "gesellix",
},
},
}
err = ds.SaveConfiguredSources(accountID, deviceID, sources)
if err != nil {
t.Fatal(err)
}
// Also need a device info file to be listed
deviceInfo := models.ServiceDeviceInfo{
AccountID: accountID,
DeviceID: deviceID,
Name: "Test Device",
}
err = ds.SaveDeviceInfo(accountID, deviceID, &deviceInfo)
if err != nil {
t.Fatal(err)
}
server := &Server{ds: ds}
r := chi.NewRouter()
r.Get("/mgmt/accounts/{accountId}", server.HandleMgmtAccountDetails)
req := httptest.NewRequest("GET", "/mgmt/accounts/1234567", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response struct {
Devices []struct {
Sources []models.FullResponseSource `json:"sources"`
} `json:"devices"`
}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if len(response.Devices) == 0 {
t.Fatal("Expected one device")
}
if len(response.Devices[0].Sources) < 2 {
t.Fatalf("Expected at least 2 sources, got %d", len(response.Devices[0].Sources))
}
// Find the gesellix source
var gesellixSource *models.FullResponseSource
for i := range response.Devices[0].Sources {
if response.Devices[0].Sources[i].ID == "10863533" {
gesellixSource = &response.Devices[0].Sources[i]
break
}
}
if gesellixSource == nil {
t.Fatal("gesellix source not found")
}
// It should have fallen back to Account name "gesellix" because DisplayName was generic "Audio"
if gesellixSource.DisplayName != "gesellix" {
t.Errorf("Expected display_name 'gesellix', got '%s'", gesellixSource.DisplayName)
}
if gesellixSource.Name != "gesellix" {
t.Errorf("Expected name 'gesellix', got '%s'", gesellixSource.Name)
}
if gesellixSource.Type != "Audio" {
t.Errorf("Expected type 'Audio', got '%s'", gesellixSource.Type)
}
// Find the generic audio source
var audioSource *models.FullResponseSource
for i := range response.Devices[0].Sources {
if response.Devices[0].Sources[i].ID == "9330201" {
audioSource = &response.Devices[0].Sources[i]
break
}
}
if audioSource == nil {
t.Fatal("audio source not found")
}
// It should still be "Audio" as there is no account fallback
if audioSource.DisplayName != "Audio" {
t.Errorf("Expected display_name 'Audio', got '%s'", audioSource.DisplayName)
}
}
+50 -1
View File
@@ -2,8 +2,10 @@
package handlers
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/url"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
@@ -14,8 +16,17 @@ import (
func (s *Server) HandleBMXRegistry(w http.ResponseWriter, _ *http.Request) {
baseURL := s.serverURL
s.mu.RLock()
dnsEnabled := s.dnsEnabled
s.mu.RUnlock()
bmxServer := baseURL
if dnsEnabled {
bmxServer = "https://content.api.bose.io"
}
content := string(bmxServicesJSON)
content = strings.ReplaceAll(content, "{BMX_SERVER}", baseURL)
content = strings.ReplaceAll(content, "{BMX_SERVER}", bmxServer)
content = strings.ReplaceAll(content, "{MEDIA_SERVER}", baseURL+"/media")
w.Header().Set("Content-Type", "application/json")
@@ -94,3 +105,41 @@ func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) {
return
}
}
// HandleCustomPlayback returns custom playback information for a given stream URL.
func (s *Server) HandleCustomPlayback(w http.ResponseWriter, r *http.Request) {
encodedURL := chi.URLParam(r, "encodedURL")
imageUrl := r.URL.Query().Get("imageUrl")
name := r.URL.Query().Get("name")
// Decode URL if it's base64 encoded
var streamUrl string
decoded, err := base64.URLEncoding.DecodeString(encodedURL)
if err != nil {
decoded, err = base64.StdEncoding.DecodeString(encodedURL)
}
if err == nil {
streamUrl = string(decoded)
} else {
// Try unescaping if it's not base64
streamUrl, err = url.PathUnescape(encodedURL)
if err != nil {
streamUrl = encodedURL
}
}
resp, err := bmx.BuildCustomStreamResponse(streamUrl, imageUrl, name)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
+42
View File
@@ -5,6 +5,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
@@ -107,3 +108,44 @@ func TestOrionPlayback(t *testing.T) {
t.Errorf("Expected name Test Orion, got %v", resp["name"])
}
}
func TestCustomPlayback(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
ts := httptest.NewServer(r)
defer ts.Close()
// Base64 encoded: http://example.com/stream
encodedURL := "aHR0cDovL2V4YW1wbGUuY29tL3N0cmVhbQ=="
imageUrl := "http://example.com/img.jpg"
name := "Test Custom"
res, err := http.Get(ts.URL + "/custom/v1/playback/" + encodedURL + "?imageUrl=" + url.QueryEscape(imageUrl) + "&name=" + url.QueryEscape(name))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
body, _ := io.ReadAll(res.Body)
var resp map[string]interface{}
_ = json.Unmarshal(body, &resp)
if resp["name"] != "Test Custom" {
t.Errorf("Expected name Test Custom, got %v", resp["name"])
}
audio := resp["audio"].(map[string]interface{})
if audio["streamUrl"] != "http://example.com/stream" {
t.Errorf("Expected streamUrl http://example.com/stream, got %v", audio["streamUrl"])
}
if resp["imageUrl"] != imageUrl {
t.Errorf("Expected imageUrl %s, got %v", imageUrl, resp["imageUrl"])
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) {
<head>
<meta charset="UTF-8">
<title>%s - Bose SoundTouch Toolkit Docs</title>
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
<link rel="icon" href="/web/img/favicon-braille.svg" type="image/svg+xml">
<link rel="stylesheet" href="/web/css/style.css">
<style>
body { margin: 0; padding: 0; display: flex; font-family: sans-serif; height: 100vh; overflow: hidden; }
+3 -3
View File
@@ -14,11 +14,11 @@ import (
func TestEventLog(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
s := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
s := NewServer(ds, nil, "http://localhost", false, false, false)
r := chi.NewRouter()
r.Post("/streaming/stats/usage", s.HandleUsageStats)
r.Get("/devices/{deviceId}/events", s.HandleGetDeviceEvents)
r.Get("/setup/devices/{deviceId}/events", s.HandleGetDeviceEvents)
t.Run("Record and Retrieve Events", func(t *testing.T) {
// 1. Post a usage stat
@@ -36,7 +36,7 @@ func TestEventLog(t *testing.T) {
}
// 2. Retrieve events
req, _ = http.NewRequest("GET", "/devices/SPEAKER1/events", nil)
req, _ = http.NewRequest("GET", "/setup/devices/SPEAKER1/events", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
+1 -1
View File
@@ -20,7 +20,7 @@ type healthResp struct {
func TestHealthEndpoint(t *testing.T) {
r := chi.NewRouter()
srv := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
srv := NewServer(nil, nil, "http://localhost", false, false, false)
r.Get("/health", srv.HandleHealth)
ts := httptest.NewServer(r)
+142 -2
View File
@@ -1,19 +1,128 @@
package handlers
import (
"crypto/rand"
"encoding/xml"
"io"
"log"
"math/big"
"net"
"net/http"
"strconv"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
"github.com/go-chi/chi/v5"
)
// HandleMargeCreateAccount creates a new account from Stockholm (XML).
func (s *Server) HandleMargeCreateAccount(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
var req models.MargeAccountCreateRequest
if err := xml.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid XML body: "+err.Error(), http.StatusBadRequest)
return
}
// Use provided ID or generate new 7-digit ID
var id string
if req.ID != "" {
id = req.ID
} else {
for {
n, _ := rand.Int(rand.Reader, big.NewInt(9000000))
id = strconv.FormatInt(n.Int64()+1000000, 10)
existing, _ := s.ds.GetAccountInfo(id)
if existing == nil || existing.IsPlaceholder {
break
}
}
}
info := &models.ServiceAccountInfo{
AccountID: id,
PreferredLanguage: req.PreferredLanguage,
}
if info.PreferredLanguage == "" {
info.PreferredLanguage = "en"
}
if err := s.ds.SaveAccountInfo(id, info); err != nil {
http.Error(w, "Failed to save account", http.StatusInternalServerError)
return
}
// Stockholm expects the account XML in response
resp := models.AccountFullResponse{
ID: id,
AccountStatus: "ACTIVE",
PreferredLanguage: info.PreferredLanguage,
}
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.WriteHeader(http.StatusCreated)
_ = xml.NewEncoder(w).Encode(resp)
}
// HandleMargeLogin handles account login from Stockholm.
func (s *Server) HandleMargeLogin(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
var req models.MargeLoginRequest
if err = xml.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid XML body: "+err.Error(), http.StatusBadRequest)
return
}
// Simple mock: find account by email or just return a default one if none exists
// For now, let's just return a fixed one for testing if nothing else matches
accounts, err := s.ds.ListAccounts()
accountID := ""
if err == nil {
for _, id := range accounts {
if id == "default" {
continue
}
// In a real system we'd check email/password
// Here we just pick the first one or use fallback
accountID = id
break
}
}
if accountID == "" {
http.Error(w, "No accounts found", http.StatusUnauthorized)
return
}
resp := models.AccountFullResponse{
ID: accountID,
AccountStatus: "ACTIVE",
PreferredLanguage: "en",
}
// Bose returns a token in the Credentials header
w.Header().Set("Credentials", "mock-token-"+accountID)
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
_ = xml.NewEncoder(w).Encode(resp)
}
// HandleMargeSourceProviders returns the Marge source providers.
func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Request) {
etag := strconv.FormatInt(time.Now().UnixMilli(), 10)
@@ -85,6 +194,37 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
log.Printf("[Marge] Device %s powered on (IP: %s)", deviceID, deviceIP)
// Persist device details provided in the power_on request
if deviceID != "" && s.ds != nil {
// Use "default" account if not found or if the device is not yet mapped to an account.
// In a real scenario, this might be resolved differently if we already have the account info.
accountID := "default"
if existing := s.findExistingDeviceInfoByDeviceID(deviceID); existing != nil && existing.AccountID != "" {
accountID = existing.AccountID
}
macAddress := ""
if len(req.DiagnosticData.DeviceLandscape.MacAddresses) > 0 {
macAddress = req.DiagnosticData.DeviceLandscape.MacAddresses[0]
}
info := &models.ServiceDeviceInfo{
DeviceID: deviceID,
AccountID: accountID,
ProductCode: req.Device.Product.ProductCode,
DeviceSerialNumber: req.Device.SerialNumber,
ProductSerialNumber: req.Device.Product.SerialNumber,
FirmwareVersion: req.Device.FirmwareVersion,
IPAddress: deviceIP,
MacAddress: macAddress,
DiscoveryMethod: "power_on",
}
if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil {
log.Printf("[Marge] Failed to save device info for %s: %v", deviceID, err)
}
}
if deviceIP != "" {
go s.PrimeDeviceWithSpotify(deviceIP)
} else {
@@ -370,7 +510,7 @@ func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, _ *http.Reques
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.Header().Set("Authorization", bearerToken.GetAuthHeader())
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write([]byte(constants.XMLHeader))
_, _ = w.Write(data)
}
@@ -379,7 +519,7 @@ func (s *Server) HandleMargeDeviceGroup(w http.ResponseWriter, _ *http.Request)
// Native firmware expects vnd.bose.streaming content type
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><group/>`))
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
}
// HandleMargeDeviceGroupServer returns grouping server information (404 by default if not a server).
+229 -3
View File
@@ -2,6 +2,8 @@ package handlers
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -10,9 +12,149 @@ import (
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestMargeCreateAccount(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
reqBody := `<account>
<preferredLanguage>de</preferredLanguage>
</account>`
res, err := http.Post(ts.URL+"/marge/streaming/account", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(reqBody))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusCreated {
t.Errorf("Expected status Created, got %v", res.Status)
}
contentType := res.Header.Get("Content-Type")
if contentType != "application/vnd.bose.streaming-v1.2+xml" {
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", contentType)
}
body, _ := io.ReadAll(res.Body)
var resp models.AccountFullResponse
if err := xml.Unmarshal(body, &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if resp.AccountStatus != "ACTIVE" {
t.Errorf("Expected AccountStatus ACTIVE, got %v", resp.AccountStatus)
}
if resp.PreferredLanguage != "de" {
t.Errorf("Expected PreferredLanguage de, got %v", resp.PreferredLanguage)
}
if len(resp.ID) != 7 {
t.Errorf("Expected 7-digit ID, got %v", resp.ID)
}
// Verify it was saved in datastore
info, err := ds.GetAccountInfo(resp.ID)
if err != nil {
t.Errorf("Failed to get account from datastore: %v", err)
}
if info == nil {
t.Error("Account not found in datastore")
} else if info.PreferredLanguage != "de" {
t.Errorf("Expected saved PreferredLanguage de, got %v", info.PreferredLanguage)
}
}
func TestMargeLogin(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
accountID := "9876543"
_ = ds.SaveAccountInfo(accountID, &models.ServiceAccountInfo{
AccountID: accountID,
PreferredLanguage: "fr",
})
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
reqBody := `<login>
<username>test@example.com</username>
<password>secret</password>
</login>`
res, err := http.Post(ts.URL+"/marge/streaming/account/login", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(reqBody))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
credentials := res.Header.Get("Credentials")
if credentials != "mock-token-"+accountID {
t.Errorf("Expected Credentials mock-token-%s, got %v", accountID, credentials)
}
body, _ := io.ReadAll(res.Body)
var resp models.AccountFullResponse
if err := xml.Unmarshal(body, &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if resp.ID != accountID {
t.Errorf("Expected ID %s, got %v", accountID, resp.ID)
}
}
func TestMargeLogin_NoAccount(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
reqBody := `<login>
<username>none@example.com</username>
<password>secret</password>
</login>`
res, err := http.Post(ts.URL+"/marge/streaming/account/login", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(reqBody))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusUnauthorized {
t.Errorf("Expected status Unauthorized, got %v", res.Status)
}
}
func TestMargeSourceProviders(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
@@ -721,7 +863,7 @@ func TestMargePowerOn(t *testing.T) {
})
t.Run("FullBody", func(t *testing.T) {
payload := `<?xml version="1.0" encoding="UTF-8" ?><device-data><device id="A81B6A536A98"><serialnumber>I6332527703739342000020</serialnumber><firmware-version>27.0.6.46330</firmware-version><product product_code="SoundTouch 10 sm2" type="5"><serialnumber>069231P63364828AE</serialnumber></product></device><diagnostic-data><device-landscape><rssi>Excellent</rssi><gateway-ip-address>192.168.1.1</gateway-ip-address><macaddresses><macaddress>A81B6A536A98</macaddress></macaddresses><ip-address>192.168.1.100</ip-address><network-connection-type>Wireless</network-connection-type></device-landscape></diagnostic-data></device-data>`
payload := `<?xml version="1.0" encoding="UTF-8" ?><device-data><device id="001122334455"><serialnumber>I6332527703739342000020</serialnumber><firmware-version>27.0.6.46330</firmware-version><product product_code="SoundTouch 10 sm2" type="5"><serialnumber>069231P63364828AE</serialnumber></product></device><diagnostic-data><device-landscape><rssi>Excellent</rssi><gateway-ip-address>192.168.1.1</gateway-ip-address><macaddresses><macaddress>001122334455</macaddress></macaddresses><ip-address>192.168.1.100</ip-address><network-connection-type>Wireless</network-connection-type></device-landscape></diagnostic-data></device-data>`
res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
@@ -731,6 +873,90 @@ func TestMargePowerOn(t *testing.T) {
t.Errorf("Expected status OK, got %v", res.Status)
}
})
t.Run("Persistence", func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
r, _ := setupRouter("http://localhost:8001", ds)
ts2 := httptest.NewServer(r)
defer ts2.Close()
deviceID := "001122334455"
serialNumber := "I6332527703739342000020"
firmware := "27.0.6.46330"
productCode := "SoundTouch 10 sm2"
productSerial := "069231P63364828AE"
ipAddress := "192.168.1.100"
macAddress := "001122334455"
payload := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" ?>
<device-data>
<device id="%s">
<serialnumber>%s</serialnumber>
<firmware-version>%s</firmware-version>
<product product_code="%s" type="5">
<serialnumber>%s</serialnumber>
</product>
</device>
<diagnostic-data>
<device-landscape>
<rssi>Excellent</rssi>
<gateway-ip-address>192.168.1.1</gateway-ip-address>
<macaddresses>
<macaddress>%s</macaddress>
</macaddresses>
<ip-address>%s</ip-address>
<network-connection-type>Wireless</network-connection-type>
</device-landscape>
</diagnostic-data>
</device-data>`, deviceID, serialNumber, firmware, productCode, productSerial, macAddress, ipAddress)
res, err := http.Post(ts2.URL+"/marge/streaming/support/power_on", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
// Verify data in datastore
info, err := ds.GetDeviceInfo("default", deviceID)
if err != nil {
t.Fatalf("Failed to get device info: %v", err)
}
if info.DeviceID != deviceID {
t.Errorf("Expected DeviceID %s, got %s", deviceID, info.DeviceID)
}
if info.DeviceSerialNumber != serialNumber {
t.Errorf("Expected SerialNumber %s, got %s", serialNumber, info.DeviceSerialNumber)
}
if info.FirmwareVersion != firmware {
t.Errorf("Expected Firmware %s, got %s", firmware, info.FirmwareVersion)
}
if info.ProductCode != productCode {
t.Errorf("Expected ProductCode %s, got %s", productCode, info.ProductCode)
}
if info.ProductSerialNumber != productSerial {
t.Errorf("Expected ProductSerialNumber %s, got %s", productSerial, info.ProductSerialNumber)
}
if info.IPAddress != ipAddress {
t.Errorf("Expected IPAddress %s, got %s", ipAddress, info.IPAddress)
}
if info.MacAddress != macAddress {
t.Errorf("Expected MacAddress %s, got %s", macAddress, info.MacAddress)
}
if info.DiscoveryMethod != "power_on" {
t.Errorf("Expected DiscoveryMethod power_on, got %s", info.DiscoveryMethod)
}
})
}
func TestMargeAdvancedFeatures(t *testing.T) {
@@ -858,8 +1084,8 @@ func TestMargeAdvancedFeatures(t *testing.T) {
})
t.Run("AddRecent_Reproduction", func(t *testing.T) {
account := "3230304"
device := "A81B6A536A98"
account := "1234567"
device := "001122334455"
// Setup sources for this device
deviceDir := ds.AccountDeviceDir(account, device)
+2 -3
View File
@@ -11,7 +11,7 @@ import (
//go:embed web/index.html
var indexHTML []byte
//go:embed web/migration/* web/stockholm-mini/* web/shared/*
//go:embed web/css/* web/js/* web/img/favicon-braille* web/img/favicon*
var webFS embed.FS
//go:embed static/media/*
@@ -50,7 +50,6 @@ func (s *Server) HandleMedia() http.HandlerFunc {
subFS, _ := fs.Sub(mediaFS, "static/media")
return func(w http.ResponseWriter, r *http.Request) {
fs := http.StripPrefix("/media/", http.FileServer(http.FS(subFS)))
fs.ServeHTTP(w, r)
http.StripPrefix("/media", http.FileServer(http.FS(subFS))).ServeHTTP(w, r)
}
}
+46 -81
View File
@@ -79,8 +79,8 @@ func TestStaticMedia(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
// Use a known file from static/media
res, err := http.Get(ts.URL + "/media/SiriusXM_Logo_Color.svg")
// Use a known file from static/media in a subdirectory
res, err := http.Get(ts.URL + "/media/bmx-icons/siriusxm-everest/SiriusXM_Logo_Color.svg")
if err != nil {
t.Fatal(err)
}
@@ -103,104 +103,33 @@ func TestStaticWeb(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
// 1. Test Migration UI CSS
res, err := http.Get(ts.URL + "/web/migration/style.css")
// 1. Test CSS
res, err := http.Get(ts.URL + "/web/css/style.css")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Migration CSS: Expected status OK, got %v", res.Status)
t.Errorf("CSS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
t.Errorf("Migration CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
}
// 2. Test Migration UI JS
res, err = http.Get(ts.URL + "/web/migration/script.js")
// 2. Test JS
res, err = http.Get(ts.URL + "/web/js/script.js")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Migration JS: Expected status OK, got %v", res.Status)
t.Errorf("JS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
t.Errorf("Migration JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
}
// 3. Test Migration UI Index
res, err = http.Get(ts.URL + "/web/migration/index.html")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Migration Index: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
t.Errorf("Migration Index: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
}
// 4. Test Stockholm Mini
res, err = http.Get(ts.URL + "/web/stockholm-mini/index.html")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Stockholm Mini: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
t.Errorf("Stockholm Mini: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
}
// 5. Test Stockholm Mini CSS
res, err = http.Get(ts.URL + "/web/stockholm-mini/style.css")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Stockholm Mini CSS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
t.Errorf("Stockholm Mini CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
}
// 6. Test Shared CSS
res, err = http.Get(ts.URL + "/web/shared/common.css")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Shared CSS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
t.Errorf("Shared CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
}
// 7. Test Shared JS
res, err = http.Get(ts.URL + "/web/shared/common.js")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Shared JS: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
t.Errorf("Shared JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
}
// 3. Test diff.min.js
@@ -213,4 +142,40 @@ func TestStaticWeb(t *testing.T) {
if res.StatusCode != http.StatusOK {
t.Errorf("Diff JS: Expected status OK, got %v", res.Status)
}
// 4. Test Favicon
res, err = http.Get(ts.URL + "/web/img/favicon-braille.svg")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Favicon: Expected status OK, got %v", res.Status)
}
if !strings.Contains(res.Header.Get("Content-Type"), "image/svg+xml") {
t.Errorf("Favicon: Expected image/svg+xml content type, got %s", res.Header.Get("Content-Type"))
}
// 5. Test old Favicon path (should be 404)
res, err = http.Get(ts.URL + "/media/favicon-braille.svg")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Errorf("Old Favicon: Expected status NotFound, got %v", res.Status)
}
// 6. Test old Favicon path in web/ (should be 404)
res, err = http.Get(ts.URL + "/web/favicon-braille.svg")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Errorf("Web Root Favicon: Expected status NotFound, got %v", res.Status)
}
}
+5 -5
View File
@@ -13,7 +13,7 @@ import (
)
func TestHandleMgmtSpotifyInit(t *testing.T) {
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
s := NewServer(nil, nil, "http://localhost", false, false, false)
// No spotify service configured
req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil)
w := httptest.NewRecorder()
@@ -45,7 +45,7 @@ func TestHandleMgmtSpotifyInit(t *testing.T) {
}
func TestHandleMgmtSpotifyAccounts(t *testing.T) {
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
s := NewServer(nil, nil, "http://localhost", false, false, false)
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
s.SetSpotifyService(svc)
@@ -91,7 +91,7 @@ func TestHandleMgmtListSpeakers(t *testing.T) {
}
func TestHandleMgmtSpotifyCallback(t *testing.T) {
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
s := NewServer(nil, nil, "http://localhost", false, false, false)
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
s.SetSpotifyService(svc)
@@ -150,7 +150,7 @@ func TestHandleMgmtSpotifyCallback(t *testing.T) {
}
func TestHandleMgmtSpotifyConfirm(t *testing.T) {
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
s := NewServer(nil, nil, "http://localhost", false, false, false)
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
s.SetSpotifyService(svc)
@@ -191,7 +191,7 @@ func TestHandleMgmtDeviceEvents(t *testing.T) {
}
func TestBasicAuthMgmt(t *testing.T) {
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false)
s := NewServer(nil, nil, "http://localhost", false, false, false)
s.SetMgmtConfig("admin", "secret123")
handler := s.BasicAuthMgmt()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+87
View File
@@ -0,0 +1,87 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"strconv"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/go-chi/chi/v5"
)
// HandleBoseToken handles the Bose-specific token refresh request from the speaker.
// POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3
func (s *Server) HandleBoseToken(w http.ResponseWriter, r *http.Request) {
sourceID := chi.URLParam(r, "sourceID")
for _, provider := range constants.StaticProviders {
if strconv.Itoa(provider.ID) == sourceID && provider.Name == "SPOTIFY" {
s.HandleBoseSpotifyToken(w, r)
return
}
}
s.HandleBoseProxy(w, r)
}
// HandleBoseLegacyToken handles the Bose-specific token refresh request (legacy or variant).
// POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token
func (s *Server) HandleBoseLegacyToken(w http.ResponseWriter, r *http.Request) {
s.HandleBoseToken(w, r)
}
// HandleBoseSpotifyToken handles the Bose-specific Spotify token refresh request from the speaker.
// POST /oauth/device/{deviceID}/music/musicprovider/15/token/cs3
func (s *Server) HandleBoseSpotifyToken(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceID")
log.Printf("[Spotify Proxy] Intercepted token request for device %s", deviceID)
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
if svc == nil {
log.Printf("[Spotify Proxy] Spotify service not configured, falling back to upstream")
s.HandleBoseProxy(w, r)
return
}
accounts := svc.GetAccounts()
if len(accounts) == 0 {
log.Printf("[Spotify Proxy] No Spotify accounts linked, falling back to upstream")
s.HandleBoseProxy(w, r)
return
}
// We use the first linked account.
accessToken, _, err := svc.GetFreshToken()
if err != nil {
log.Printf("[Spotify Proxy] Failed to get fresh token: %v. Falling back to upstream", err)
s.HandleBoseProxy(w, r)
return
}
// Format response as expected by Bose firmware.
// Based on observed interactions, it's a JSON object with access_token.
// The "scope" and other fields might be needed by some firmware versions.
response := map[string]interface{}{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": 3600,
// These scopes are typical for what Bose requests.
"scope": "playlist-read-private playlist-read-collaborative streaming user-library-read user-library-modify playlist-modify-private playlist-modify-public user-read-email user-read-private user-top-read",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Proxy-Origin", "self")
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("[Spotify Proxy] Failed to encode response: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
+134
View File
@@ -0,0 +1,134 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/go-chi/chi/v5"
)
func TestHandleBoseSpotifyToken_LocalResponse(t *testing.T) {
tmpDir := t.TempDir()
ds := datastore.NewDataStore(tmpDir)
server := NewServer(ds, nil, "http://localhost", false, false, false)
// Wait, I can't easily inject an account into spotify.Service from here because fields are private.
// Let's check if there's any other way.
// I could mock the spotify.Service if it was an interface, but it's a struct.
// ss.load() expects accounts in tmpDir/spotify/accounts.json
spotifyDir := filepath.Join(tmpDir, "spotify")
_ = os.MkdirAll(spotifyDir, 0755)
account := map[string]interface{}{
"user1": map[string]interface{}{
"user_id": "user1",
"display_name": "Test User",
"access_token": "valid-token",
"refresh_token": "refresh-token",
"expires_at": time.Now().Add(1 * time.Hour).Unix(),
},
}
data, err := json.Marshal(account)
if err != nil {
t.Fatalf("Failed to marshal account: %v", err)
}
_ = os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), data, 0644)
// Initialize ss so it loads the data
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
server.SetSpotifyService(ss)
// chi.URLParam works when using chi router
r := chi.NewRouter()
r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token/cs3", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
if w.Header().Get("X-Proxy-Origin") != "self" {
t.Errorf("Expected X-Proxy-Origin: self, got %s", w.Header().Get("X-Proxy-Origin"))
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp["access_token"] != "valid-token" {
t.Errorf("Expected access_token 'valid-token', got %v", resp["access_token"])
}
}
func TestHandleBoseSpotifyToken_FallbackToProxy(t *testing.T) {
tmpDir := t.TempDir()
ds := datastore.NewDataStore(tmpDir)
server := NewServer(ds, nil, "http://localhost", false, false, false)
// Mirroring must be enabled for HandleBoseProxy to work (based on previous changes)
// Actually I reverted that, so it should work regardless of MirrorEnabled now.
server.SetMirrorSettings(true, nil, nil, "")
// chi.URLParam works when using chi router
r := chi.NewRouter()
r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
// Since there's no Spotify service, it should fall back to HandleBoseProxy.
// HandleBoseProxy will try to contact streaming.bose.com.
// We can check if it returns a 502 or 404 (since we are not actually proxying to real Bose).
req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token/cs3", nil)
req.Host = "localhost" // use localhost to avoid real network call
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// If it fell back to proxy, it should NOT have X-Proxy-Origin: self
if w.Header().Get("X-Proxy-Origin") == "self" {
t.Errorf("Expected fallback to proxy, but got X-Proxy-Origin: self")
}
// HandleBoseProxy sets X-Proxy-Origin: upstream
if w.Header().Get("X-Proxy-Origin") != "upstream" {
// It might fail before setting the header if the target host is invalid,
// but our HandleBoseProxy sets it in ModifyResponse.
// If it fails to connect, it might return 502 without the header.
if w.Code != http.StatusBadGateway && w.Code != http.StatusNotFound {
t.Errorf("Expected fallback to proxy (upstream), got status %d and origin %s", w.Code, w.Header().Get("X-Proxy-Origin"))
}
}
}
func TestHandleBoseLegacyToken(t *testing.T) {
tmpDir := t.TempDir()
ds := datastore.NewDataStore(tmpDir)
server := NewServer(ds, nil, "http://localhost", false, false, false)
r := chi.NewRouter()
r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token", server.HandleBoseLegacyToken)
// Since we are not configuring Spotify, it should fall back to proxy
req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token", nil)
req.Host = "localhost"
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Header().Get("X-Proxy-Origin") == "self" {
t.Errorf("Expected fallback to proxy")
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ func TestHandleProxyRequest_RequestBodyRecording(t *testing.T) {
defer backend.Close()
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
server := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
server := NewServer(ds, nil, "http://localhost", false, false, false)
server.recordEnabled = true
server.proxyLogBody = true
recorder := proxy.NewRecorder(tmpDir)
+49 -44
View File
@@ -156,6 +156,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
dnsBindAddr := s.dnsBindAddr
mirrorEnabled := s.mirrorEnabled
mirrorEndpoints := s.mirrorEndpoints
skipMirrorEndpoints := s.skipMirrorEndpoints
preferredSource := s.preferredSource
internalPaths := s.internalPaths
redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled
@@ -166,24 +167,25 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
dnsRunning, actualBind := s.GetDNSRunning()
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"server_url": serverURL,
"https_server_url": httpsServerURL,
"discovery_interval": discoveryInterval,
"discovery_enabled": discoveryEnabled,
"dns_enabled": dnsEnabled,
"dns_running": dnsRunning,
"dns_actual_bind": actualBind,
"dns_upstream": strings.Join(dnsUpstream, ","),
"dns_bind_addr": dnsBindAddr,
"mirror_enabled": mirrorEnabled,
"mirror_endpoints": mirrorEndpoints,
"preferred_source": preferredSource,
"internal_paths": internalPaths,
"redact_logs": redact,
"log_bodies": logBody,
"record_interactions": record,
"shortcuts": shortcuts,
"spotify_configured": spotifyConfigured,
"server_url": serverURL,
"https_server_url": httpsServerURL,
"discovery_interval": discoveryInterval,
"discovery_enabled": discoveryEnabled,
"dns_enabled": dnsEnabled,
"dns_running": dnsRunning,
"dns_actual_bind": actualBind,
"dns_upstream": strings.Join(dnsUpstream, ","),
"dns_bind_addr": dnsBindAddr,
"mirror_enabled": mirrorEnabled,
"mirror_endpoints": mirrorEndpoints,
"skip_mirror_endpoints": skipMirrorEndpoints,
"preferred_source": preferredSource,
"internal_paths": internalPaths,
"redact_logs": redact,
"log_bodies": logBody,
"record_interactions": record,
"shortcuts": shortcuts,
"spotify_configured": spotifyConfigured,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -193,17 +195,18 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
// HandleUpdateSettings updates the service settings.
func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var settings struct {
ServerURL string `json:"server_url"`
DiscoveryInterval string `json:"discovery_interval"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream string `json:"dns_upstream"`
DNSBindAddr string `json:"dns_bind_addr"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints"`
PreferredSource string `json:"preferred_source"`
InternalPaths []string `json:"internal_paths"`
Shortcuts map[string]int `json:"shortcuts"`
ServerURL string `json:"server_url"`
DiscoveryInterval string `json:"discovery_interval"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream string `json:"dns_upstream"`
DNSBindAddr string `json:"dns_bind_addr"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints"`
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints"`
PreferredSource string `json:"preferred_source"`
InternalPaths []string `json:"internal_paths"`
Shortcuts map[string]int `json:"shortcuts"`
}
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -249,6 +252,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
s.mirrorEnabled = settings.MirrorEnabled
s.mirrorEndpoints = settings.MirrorEndpoints
s.skipMirrorEndpoints = settings.SkipMirrorEndpoints
s.preferredSource = settings.PreferredSource
s.internalPaths = settings.InternalPaths
@@ -269,21 +273,22 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
log.Printf("Saving updated settings to %s/settings.json", s.ds.DataDir)
err = s.ds.SaveSettings(datastore.Settings{
ServerURL: s.serverURL,
HTTPServerURL: currentHTTPS,
RedactLogs: currentRedact,
LogBodies: currentLogBody,
RecordInteractions: currentRecord,
DiscoveryInterval: s.discoveryInterval.String(),
DiscoveryEnabled: s.discoveryEnabled,
DNSEnabled: s.dnsEnabled,
DNSUpstream: s.dnsUpstream,
DNSBindAddr: s.dnsBindAddr,
MirrorEnabled: s.mirrorEnabled,
MirrorEndpoints: s.mirrorEndpoints,
PreferredSource: s.preferredSource,
InternalPaths: s.internalPaths,
Shortcuts: s.shortcuts,
ServerURL: s.serverURL,
HTTPServerURL: currentHTTPS,
RedactLogs: currentRedact,
LogBodies: currentLogBody,
RecordInteractions: currentRecord,
DiscoveryInterval: s.discoveryInterval.String(),
DiscoveryEnabled: s.discoveryEnabled,
DNSEnabled: s.dnsEnabled,
DNSUpstream: s.dnsUpstream,
DNSBindAddr: s.dnsBindAddr,
MirrorEnabled: s.mirrorEnabled,
MirrorEndpoints: s.mirrorEndpoints,
SkipMirrorEndpoints: s.skipMirrorEndpoints,
PreferredSource: s.preferredSource,
InternalPaths: s.internalPaths,
Shortcuts: s.shortcuts,
})
dnsEnabled := s.dnsEnabled
+20 -23
View File
@@ -232,8 +232,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("CA: Unexpected content type: %s", res.Header.Get("Content-Type"))
}
// 2. Test POST /setup/devices/{deviceIP}/migrate?method=hosts
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/migrate?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
// 2. Test POST /setup/migrate/{deviceIP}?method=hosts
res, err = http.Post(ts.URL+"/setup/migrate/192.168.1.10?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -254,8 +254,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("Migrate: Expected output field in response")
}
// 3. Test POST /setup/devices/{deviceIP}/trust-ca
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/trust-ca", "application/json", nil)
// 3. Test POST /setup/trust-ca/{deviceIP}
res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -275,8 +275,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("TrustCA: Expected output field in response")
}
// 4. Test POST /devices/{deviceIP}/reboot
res, err = http.Post(ts.URL+"/devices/192.168.1.10/reboot", "application/json", nil)
// 4. Test POST /setup/reboot/{deviceIP}
res, err = http.Post(ts.URL+"/setup/reboot/192.168.1.10", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -296,8 +296,8 @@ func TestMigrationAndCA(t *testing.T) {
t.Errorf("Reboot: Expected output field in response")
}
// 5. Test POST /setup/devices/{deviceIP}/remove-remote-services
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/remove-remote-services", "application/json", nil)
// 5. Test POST /setup/remove-remote-services/{deviceIP}
res, err = http.Post(ts.URL+"/setup/remove-remote-services/192.168.1.10", "application/json", nil)
if err != nil {
t.Fatal(err)
}
@@ -329,20 +329,17 @@ func TestRemoveDevice(t *testing.T) {
_ = ds.Initialize()
// Setup a dummy device in the datastore
account := "acc1"
account := "test-account"
deviceID := "TEST-DEVICE-ID"
// Register device in datastore so HandleRemoveDevice works
_ = ds.SaveDeviceInfo(account, deviceID, &models.ServiceDeviceInfo{
DeviceID: deviceID,
AccountID: account,
IPAddress: "192.168.1.100",
})
// Verify directory exists where datastore expects it
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
if _, err := os.Stat(deviceDir); err != nil {
t.Fatalf("Device directory was not created by SaveDeviceInfo: %v", err)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("Failed to create device dir: %v", err)
}
infoFile := filepath.Join(deviceDir, "DeviceInfo.xml")
infoXML := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="TEST-DEVICE-ID"><name>Test Device</name><type>SoundTouch 10</type></info>`
if err := os.WriteFile(infoFile, []byte(infoXML), 0644); err != nil {
t.Fatalf("Failed to create device info file: %v", err)
}
r, _ := setupRouter("http://localhost:8001", ds)
@@ -350,7 +347,7 @@ func TestRemoveDevice(t *testing.T) {
defer ts.Close()
// 1. Verify device exists
res, err := http.Get(ts.URL + "/devices")
res, err := http.Get(ts.URL + "/setup/devices")
if err != nil {
t.Fatal(err)
}
@@ -373,7 +370,7 @@ func TestRemoveDevice(t *testing.T) {
}
// 2. Remove device
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/devices/"+deviceID, nil)
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/setup/devices/"+deviceID, nil)
if err != nil {
t.Fatal(err)
}
@@ -388,7 +385,7 @@ func TestRemoveDevice(t *testing.T) {
}
// 3. Verify device is gone
res, err = http.Get(ts.URL + "/devices")
res, err = http.Get(ts.URL + "/setup/devices")
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -20,7 +20,7 @@ func TestStatsHandlers(t *testing.T) {
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
s := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
s := NewServer(ds, nil, "http://localhost", false, false, false)
t.Run("HandleUsageStats XML", func(t *testing.T) {
xmlData := `
-124
View File
@@ -1,124 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
)
// HandleGetStockholmDeviceInfo returns live information for a device.
func (s *Server) HandleGetStockholmDeviceInfo(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
// Include IP address in both snake_case and camelCase for frontend compatibility
type deviceInfoResponse struct {
*setup.DeviceInfoXML `json:",inline"`
IPAddress string `json:"ip_address"`
IPAddressCamel string `json:"ipAddress,omitempty"`
}
resp := deviceInfoResponse{
DeviceInfoXML: info,
IPAddress: deviceIP,
IPAddressCamel: deviceIP,
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// HandleDeviceKey sends a key command to a device.
func (s *Server) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
key := chi.URLParam(r, "key")
if deviceID == "" || key == "" {
http.Error(w, "Device ID and Key are required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
c := client.NewClientFromHost(deviceIP)
err = c.SendKey(key)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to send key %s to %s: %v", key, deviceIP, err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Key sent"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// HandleDeviceVolume sets the volume level for a device.
func (s *Server) HandleDeviceVolume(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
levelStr := chi.URLParam(r, "level")
if deviceID == "" || levelStr == "" {
http.Error(w, "Device ID and Level are required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
level, err := strconv.Atoi(levelStr)
if err != nil {
http.Error(w, "Invalid volume level", http.StatusBadRequest)
return
}
c := client.NewClientFromHost(deviceIP)
err = c.SetVolume(level)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to set volume to %d on %s: %v", level, deviceIP, err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Volume set"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
-203
View File
@@ -1,203 +0,0 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(_ *http.Request) bool { return true },
}
const (
pongWait = 40 * time.Second
pingPeriod = 20 * time.Second // must be less than pongWait
)
// HandleDeviceWebSocket upgrades the connection and proxies device WebSocket events to the browser.
func (s *Server) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
http.Error(w, "Device ID is required", http.StatusBadRequest)
return
}
deviceIP, err := s.lookupIP(deviceID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Upgrade the HTTP connection to a WebSocket for the browser
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
// Create a SoundTouch WebSocket client for the target device
c := client.NewClientFromHost(deviceIP)
wsClient := c.NewWebSocketClient(client.DefaultWebSocketConfig())
// Channel-based write pump per Gorilla best practices
sendCh := make(chan []byte, 64) // buffer to smooth bursts
closeCh := make(chan struct{})
// Helper to enqueue JSON messages; drop if buffer is full to avoid blocking
enqueue := func(v interface{}) {
b, err := json.Marshal(v)
if err != nil {
return
}
select {
case sendCh <- b:
default:
// drop to protect connection under burst
}
}
// Reader: we don't expect messages from the browser; just keep the
// connection alive by processing control frames and detect close.
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(pongWait))
})
go func() {
defer func() {
close(closeCh)
_ = wsClient.Disconnect()
_ = conn.Close()
}()
for {
mt, _, err := conn.ReadMessage()
if err != nil {
log.Printf("[WebSocket] Browser connection closed for %s: %v", deviceIP, err)
return
}
if mt == websocket.CloseMessage {
return
}
}
}()
// Writer: single writer goroutine handles JSON writes and ping keepalive
go func() {
pingTicker := time.NewTicker(pingPeriod)
defer func() {
pingTicker.Stop()
_ = conn.Close()
}()
for {
select {
case msg, ok := <-sendCh:
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-pingTicker.C:
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
case <-closeCh:
return
}
}
}()
// Forward typed events with a simple envelope into the send queue
wsClient.SetHandlers(&models.WebSocketEventHandlers{
OnNowPlaying: func(e *models.NowPlayingUpdatedEvent) {
enqueue(map[string]interface{}{"type": "nowPlayingUpdated", "payload": e})
},
OnVolumeUpdated: func(e *models.VolumeUpdatedEvent) {
enqueue(map[string]interface{}{"type": "volumeUpdated", "payload": e})
},
OnConnectionState: func(e *models.ConnectionStateUpdatedEvent) {
enqueue(map[string]interface{}{"type": "connectionStateUpdated", "payload": e})
},
OnPresetUpdated: func(e *models.PresetUpdatedEvent) {
enqueue(map[string]interface{}{"type": "presetUpdated", "payload": e})
},
OnZoneUpdated: func(e *models.ZoneUpdatedEvent) {
enqueue(map[string]interface{}{"type": "zoneUpdated", "payload": e})
},
OnBassUpdated: func(e *models.BassUpdatedEvent) {
enqueue(map[string]interface{}{"type": "bassUpdated", "payload": e})
},
OnUnknownEvent: func(event *models.WebSocketEvent) {
bytes, _ := json.Marshal(event)
enqueue(map[string]interface{}{"type": "unknown", "payload": json.RawMessage(bytes)})
},
OnSpecialMessage: func(msg *models.SpecialMessage) {
enqueue(map[string]interface{}{"type": "special", "payload": msg})
},
})
// Add a separate goroutine to monitor the device connection status
go func() {
wsClient.Wait()
log.Printf("[WebSocket] Device %s client terminated", deviceIP)
_ = conn.Close()
}()
// Connect to the device WebSocket
if err := wsClient.Connect(); err != nil {
enqueue(map[string]interface{}{"type": "error", "message": err.Error()})
return
}
// Optional: send an initial snapshot for convenience
go func() {
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
if err != nil {
return
}
// Supplement with volume and now playing
c := client.NewClientFromHost(deviceIP)
payload := map[string]interface{}{
"deviceID": info.DeviceID,
"name": info.Name,
"type": info.Type,
//"maccAddress": info.MaccAddress,
"serialNumber": info.SerialNumber,
"softwareVersion": info.SoftwareVer,
// Provide IP in both styles for frontend robustness
"ip_address": deviceIP,
"ipAddress": deviceIP,
}
if vol, err := c.GetVolume(); err == nil {
payload["volume"] = vol
// Also add at top level for flatter frontend parsing
payload["actualVolume"] = vol.ActualVolume
}
if np, err := c.GetNowPlaying(); err == nil {
payload["nowPlaying"] = np
}
enqueue(map[string]interface{}{"type": "snapshotInfo", "payload": payload})
}()
}
+2 -2
View File
@@ -23,7 +23,7 @@ func TestInteractionHandlers(t *testing.T) {
defer os.RemoveAll(tmpDir)
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
server := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
server := NewServer(ds, nil, "http://localhost", false, false, false)
t.Run("HandleGetInteractionStats_NoRecorder", func(t *testing.T) {
req := httptest.NewRequest("GET", "/setup/interaction-stats", nil)
@@ -151,7 +151,7 @@ func TestRecordMiddleware(t *testing.T) {
defer os.RemoveAll(tmpDir)
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
server := NewServer(ds, nil, "http://localhost", false, false, true, false, false)
server := NewServer(ds, nil, "http://localhost", false, false, true)
recorder := proxy.NewRecorder(tmpDir)
server.SetRecorder(recorder)
@@ -23,10 +23,10 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
defer os.RemoveAll(tempDir)
// Mock device info response (real-world example)
deviceInfoXML := `<info deviceID="A81B6A536A98">
deviceInfoXML := `<info deviceID="001122334455">
<name>Sound Machinechen</name>
<type>SoundTouch 10</type>
<margeAccountUUID>3230304</margeAccountUUID>
<margeAccountUUID>1234567</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
@@ -41,7 +41,7 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
</components>
<margeURL>https://streaming.bose.com</margeURL>
<networkInfo type="SCM">
<macAddress>A81B6A536A98</macAddress>
<macAddress>001122334455</macAddress>
<ipAddress>192.168.1.100</ipAddress>
</networkInfo>
<networkInfo type="SMSC">
@@ -74,7 +74,7 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
sm := setup.NewManager(server.URL, ds, nil)
// Create server instance
srv := NewServer(ds, sm, "http://localhost", false, false, false, false, false)
srv := NewServer(ds, sm, "http://localhost", false, false, false)
t.Logf("Test scenario:")
t.Logf(" Device IP: %s", deviceIP)
@@ -98,8 +98,8 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
srv.handleDiscoveredDevice(discoveredDevice)
// 3. Verify the device was saved with MAC address as deviceID
expectedDeviceID := "A81B6A536A98" // MAC address from /info
expectedAccountID := "3230304" // From margeAccountUUID
expectedDeviceID := "001122334455" // MAC address from /info
expectedAccountID := "1234567" // From margeAccountUUID
deviceInfo, err := ds.GetDeviceInfo(expectedAccountID, expectedDeviceID)
if err != nil {
@@ -135,8 +135,8 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
t.Errorf("Expected productCode 'SoundTouch 10 sm2', got '%s'", deviceInfo.ProductCode)
}
if deviceInfo.MacAddress != "A81B6A536A98" {
t.Errorf("Expected macAddress 'A81B6A536A98', got '%s'", deviceInfo.MacAddress)
if deviceInfo.MacAddress != "001122334455" {
t.Errorf("Expected macAddress '001122334455', got '%s'", deviceInfo.MacAddress)
}
if deviceInfo.DeviceSerialNumber != "I6332527703739342000020" {
@@ -193,7 +193,7 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
// Verify MAC address in networkInfo
macFound := false
for _, net := range savedXML.NetworkInfo {
if net.Type == "SCM" && net.MacAddress == "A81B6A536A98" {
if net.Type == "SCM" && net.MacAddress == "001122334455" {
macFound = true
break
}
@@ -212,14 +212,14 @@ func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
}
// 7. Test MAC address resolution
resolvedDir := ds.AccountDeviceDir(expectedAccountID, "A81B6A536A98") // Use MAC as device lookup
resolvedDir := ds.AccountDeviceDir(expectedAccountID, "001122334455") // Use MAC as device lookup
expectedResolvedDir := ds.AccountDeviceDir(expectedAccountID, expectedDeviceID)
if resolvedDir != expectedResolvedDir {
t.Errorf("MAC resolution failed. Expected '%s', got '%s'", expectedResolvedDir, resolvedDir)
} else {
t.Logf("\n5. MAC address resolution verified:")
t.Logf(" MAC 'A81B6A536A98' resolves to correct device directory")
t.Logf(" MAC '001122334455' resolves to correct device directory")
}
t.Logf("\n✅ MAC-based device discovery integration test passed!")
@@ -241,7 +241,7 @@ func TestMACBasedDeviceDiscovery_MigrationScenario(t *testing.T) {
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
accountID := "3230304"
accountID := "1234567"
// 1. Create an existing device entry using IP address (old style)
oldDeviceID := "192.168.1.100"
@@ -281,10 +281,10 @@ func TestMACBasedDeviceDiscovery_MigrationScenario(t *testing.T) {
t.Logf(" Test presets saved: %d", len(testPresets))
// 2. Mock the same device now providing proper /info response
deviceInfoXML := `<info deviceID="A81B6A536A98">
deviceInfoXML := `<info deviceID="001122334455">
<name>Sound Machinechen</name>
<type>SoundTouch 10</type>
<margeAccountUUID>3230304</margeAccountUUID>
<margeAccountUUID>1234567</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
@@ -293,7 +293,7 @@ func TestMACBasedDeviceDiscovery_MigrationScenario(t *testing.T) {
</component>
</components>
<networkInfo type="SCM">
<macAddress>A81B6A536A98</macAddress>
<macAddress>001122334455</macAddress>
<ipAddress>192.168.1.100</ipAddress>
</networkInfo>
<moduleType>sm2</moduleType>
@@ -311,7 +311,7 @@ func TestMACBasedDeviceDiscovery_MigrationScenario(t *testing.T) {
deviceIP := server.URL[len("http://"):]
sm := setup.NewManager(server.URL, ds, nil)
srv := NewServer(ds, sm, server.URL, false, false, false, false, false)
srv := NewServer(ds, sm, server.URL, false, false, false)
// 3. Simulate rediscovery of the same device (now with /info working)
discoveredDevice := models.DiscoveredDevice{
@@ -326,7 +326,7 @@ func TestMACBasedDeviceDiscovery_MigrationScenario(t *testing.T) {
srv.handleDiscoveredDevice(discoveredDevice)
// 5. Verify new device exists with MAC as deviceID
newDeviceID := "A81B6A536A98"
newDeviceID := "001122334455"
newInfo, err := ds.GetDeviceInfo(accountID, newDeviceID)
if err != nil {
t.Fatalf("Failed to get migrated device info: %v", err)
@@ -380,7 +380,7 @@ func TestMACBasedDeviceDiscovery_FallbackScenario(t *testing.T) {
ds := datastore.NewDataStore(tempDir)
sm := setup.NewManager(server.URL, ds, nil)
srv := NewServer(ds, sm, server.URL, false, false, false, false, false)
srv := NewServer(ds, sm, server.URL, false, false, false)
// Simulate device discovery with UPnP providing serial
discoveredDevice := models.DiscoveredDevice{
@@ -23,9 +23,9 @@ func TestMacMappingIntegration_HTTPHandler(t *testing.T) {
defer os.RemoveAll(tmpDir)
// Setup test data (same as the issue description)
accountID := "3230304"
accountID := "1234567"
serialNumber := "I6332527703739342000020"
macAddress := "A81B6A536A98"
macAddress := "001122334455"
// Create directory structure using serial number
deviceDir := filepath.Join(tmpDir, "accounts", accountID, "devices", serialNumber)
@@ -97,7 +97,7 @@ func TestMacMappingIntegration_HTTPHandler(t *testing.T) {
t.Fatalf("failed to initialize datastore: %v", err)
}
server := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
server := NewServer(ds, nil, "http://localhost", false, false, false)
// Setup router with the exact same route as in production
router := chi.NewRouter()
@@ -265,8 +265,8 @@ func TestMacMappingDebug(t *testing.T) {
serial string
mac string
}{
{"3230304", "I6332527703739342000020", "A81B6A536A98"},
{"3230304", "J1234567890123456789012", "B92C7B647BA9"},
{"1234567", "I6332527703739342000020", "001122334455"},
{"1234567", "J1234567890123456789012", "B92C7B647BA9"},
{"5678901", "K9876543210987654321098", "C03D8C758CAA"},
}
+6 -46
View File
@@ -6,7 +6,7 @@ import (
)
func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) {
server := NewServer(ds, nil, targetURL, false, false, false, false, false)
server := NewServer(ds, nil, targetURL, false, false, false)
r := chi.NewRouter()
r.Use(server.OriginMiddleware)
@@ -35,6 +35,7 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
streamingRoutes := func(r chi.Router) {
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
@@ -56,6 +57,8 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
r.Get("/account/{account}/full", server.HandleMargeAccountFull)
r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
r.Post("/account", server.HandleMargeCreateAccount)
r.Post("/account/login", server.HandleMargeLogin)
}
accountsRoutes := func(r chi.Router) {
@@ -91,31 +94,12 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
})
// Setup Devices for tests
r.Route("/devices", func(r chi.Router) {
r.Get("/", server.HandleListDiscoveredDevices)
r.Post("/", server.HandleAddManualDevice)
r.Route("/{deviceId}", func(r chi.Router) {
r.Delete("/", server.HandleRemoveDevice)
r.Get("/events", server.HandleGetDeviceEvents)
r.Get("/info", server.HandleGetDeviceInfo)
r.Get("/ws", server.HandleDeviceWebSocket)
r.Post("/key/{key}", server.HandleDeviceKey)
r.Post("/volume/{level}", server.HandleDeviceVolume)
r.Post("/reboot", server.HandleRebootDevice)
})
})
r.Get("/version", server.HandleGetVersionInfo)
// Setup Setup for tests
r.Route("/setup", func(r chi.Router) {
r.Post("/discover", server.HandleTriggerDiscovery)
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
r.Get("/settings", server.HandleGetSettings)
r.Post("/settings", server.HandleUpdateSettings)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
@@ -127,30 +111,6 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/interaction-stats", server.HandleGetInteractionStats)
r.Get("/interactions", server.HandleListInteractions)
r.Get("/interaction-content", server.HandleGetInteractionContent)
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
r.Route("/devices/{deviceId}", func(r chi.Router) {
r.Get("/summary", server.HandleGetMigrationSummary)
r.Post("/migrate", server.HandleMigrateDevice)
r.Post("/revert", server.HandleRevertMigration)
r.Post("/trust-ca", server.HandleTrustCACert)
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
r.Post("/backup", server.HandleBackupConfig)
r.Post("/sync", server.HandleInitialSync)
r.Post("/test-connection", server.HandleTestConnection)
r.Post("/test-hosts", server.HandleTestHostsRedirection)
r.Post("/test-dns", server.HandleTestDNSRedirection)
})
})
r.NotFound(server.HandleNotFound)
-490
View File
@@ -1,490 +0,0 @@
package handlers
import (
"fmt"
"log"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
// DeviceMigrationDiagnostic provides detailed analysis of device migration scenarios
type DeviceMigrationDiagnostic struct {
server *Server
}
// NewDeviceMigrationDiagnostic creates a new diagnostic instance
func NewDeviceMigrationDiagnostic(server *Server) *DeviceMigrationDiagnostic {
return &DeviceMigrationDiagnostic{server: server}
}
// DiagnoseDeviceMigration analyzes why a specific device might not be migrating correctly
func (d *DeviceMigrationDiagnostic) DiagnoseDeviceMigration(deviceIP string) error {
log.Printf("=== Device Migration Diagnostic for %s ===", deviceIP)
// 1. Fetch live device info
liveInfo, err := d.server.sm.GetLiveDeviceInfo(deviceIP)
if err != nil {
log.Printf("❌ Failed to fetch /info from %s: %v", deviceIP, err)
return fmt.Errorf("cannot fetch /info from %s: %w", deviceIP, err)
}
log.Printf("✅ Successfully fetched /info from %s", deviceIP)
log.Printf(" Device ID (MAC): %s", liveInfo.DeviceID)
log.Printf(" Device Name: %s", liveInfo.Name)
log.Printf(" Product: %s %s", liveInfo.Type, liveInfo.ModuleType)
log.Printf(" Account: %s", liveInfo.MargeAccountUUID)
log.Printf(" Component Serial: %s", liveInfo.SerialNumber)
log.Printf(" Primary MAC: %s", liveInfo.GetPrimaryMacAddress())
// 2. List all existing devices
allDevices, err := d.server.ds.ListAllDevices()
if err != nil {
log.Printf("❌ Failed to list devices: %v", err)
return fmt.Errorf("failed to list devices: %w", err)
}
log.Printf("\n📋 Found %d existing devices in datastore:", len(allDevices))
devicesByAccount := make(map[string][]models.ServiceDeviceInfo)
for i := range allDevices {
device := &allDevices[i]
devicesByAccount[device.AccountID] = append(devicesByAccount[device.AccountID], *device)
}
for accountID, devices := range devicesByAccount {
log.Printf(" Account %s: %d devices", accountID, len(devices))
for i := range devices {
device := &devices[i]
log.Printf(" %d. %s", i+1, device.DeviceID)
log.Printf(" Name: %s", device.Name)
log.Printf(" IP: %s", device.IPAddress)
log.Printf(" Serial: %s", device.DeviceSerialNumber)
log.Printf(" MAC: %s", device.MacAddress)
log.Printf(" Product: %s", device.ProductCode)
log.Printf(" Discovery: %s", device.DiscoveryMethod)
}
}
// 3. Simulate discovery and check matching
log.Printf("\n🔍 Testing migration candidate matching:")
// Test different discovery scenarios
testDiscoveries := []models.DiscoveredDevice{
{
Host: deviceIP,
Name: "Current Discovery",
SerialNo: "",
DiscoveryMethod: "Manual",
},
{
Host: deviceIP,
Name: "With Live Serial",
SerialNo: liveInfo.SerialNumber,
DiscoveryMethod: "UPnP",
},
}
// Add test with different IPs that might match existing devices
seenIPs := make(map[string]bool)
for i := range allDevices {
device := &allDevices[i]
if device.IPAddress != "" && device.IPAddress != deviceIP && !seenIPs[device.IPAddress] {
seenIPs[device.IPAddress] = true
testDiscoveries = append(testDiscoveries, models.DiscoveredDevice{
Host: device.IPAddress,
Name: "Previous IP Test",
SerialNo: "",
DiscoveryMethod: "Test",
})
}
}
for i := range testDiscoveries {
testDiscovery := &testDiscoveries[i]
log.Printf("\n Test Scenario %d: %s (IP: %s, Serial: %s)",
i+1, testDiscovery.Name, testDiscovery.Host, testDiscovery.SerialNo)
matches := d.server.findAllExistingDeviceVariants(*testDiscovery, liveInfo)
if len(matches) == 0 {
log.Printf(" ❌ No migration candidates found")
} else {
log.Printf(" ✅ Found %d migration candidate(s):", len(matches))
for i := range matches {
match := &matches[i]
if match.DeviceID == liveInfo.DeviceID {
log.Printf(" - %s ⚠️ (already uses target MAC)", match.DeviceID)
} else {
log.Printf(" - %s", match.DeviceID)
}
}
}
}
// 4. Detailed matching analysis
log.Printf("\n🔬 Detailed Matching Analysis:")
log.Printf(" Looking for devices that should match MAC %s...", liveInfo.DeviceID)
potentialMatches := d.findPotentialMatches(allDevices, liveInfo)
if len(potentialMatches) == 0 {
log.Printf(" ❌ No potential matches found")
log.Printf("\n💡 Recommendations:")
log.Printf(" - This appears to be a completely new device")
log.Printf(" - Device will be created with MAC-based ID: %s", liveInfo.DeviceID)
log.Printf(" - Account: %s", liveInfo.MargeAccountUUID)
} else {
log.Printf(" ✅ Found %d potential match(es):", len(potentialMatches))
for i := range potentialMatches {
d.explainMatch(potentialMatches[i], liveInfo)
}
log.Printf("\n💡 Migration Recommendations:")
for i := range potentialMatches {
match := potentialMatches[i]
if match.DeviceID != liveInfo.DeviceID {
log.Printf(" - Migrate %s → %s", match.DeviceID, liveInfo.DeviceID)
log.Printf(" Reason: %s", d.getMatchReason(match, liveInfo))
}
}
}
log.Printf("\n=== End Diagnostic ===")
return nil
}
// findPotentialMatches finds devices that could potentially be the same device
func (d *DeviceMigrationDiagnostic) findPotentialMatches(allDevices []models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) []models.ServiceDeviceInfo {
var matches []models.ServiceDeviceInfo
for i := range allDevices {
device := &allDevices[i]
if d.couldBeMatch(*device, liveInfo) {
matches = append(matches, *device)
}
}
return matches
}
// couldBeMatch determines if a device could potentially be the same physical device
func (d *DeviceMigrationDiagnostic) couldBeMatch(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) bool {
// 1. Serial number match
if liveInfo.SerialNumber != "" && device.DeviceSerialNumber == liveInfo.SerialNumber {
return true
}
// 2. DeviceID is the serial
if liveInfo.SerialNumber != "" && device.DeviceID == liveInfo.SerialNumber {
return true
}
// 3. MAC address match
primaryMAC := liveInfo.GetPrimaryMacAddress()
if primaryMAC != "" && device.MacAddress == primaryMAC {
return true
}
// 4. DeviceID is already the MAC
if device.DeviceID == liveInfo.DeviceID {
return true
}
// 5. Name and product similarity
if liveInfo.Name != "" && device.Name == liveInfo.Name {
expectedProduct := liveInfo.Type + " " + liveInfo.ModuleType
if device.ProductCode == expectedProduct ||
device.ProductCode == liveInfo.Type ||
strings.Contains(device.ProductCode, liveInfo.Type) ||
strings.Contains(expectedProduct, device.ProductCode) {
return true
}
}
// 6. Check if device product serial matches any component
for _, comp := range liveInfo.Components {
if comp.SerialNumber != "" && device.ProductSerialNumber == comp.SerialNumber {
return true
}
}
return false
}
// explainMatch provides detailed explanation of why a device matches
func (d *DeviceMigrationDiagnostic) explainMatch(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) {
log.Printf(" 📋 Device: %s", device.DeviceID)
log.Printf(" Account: %s", device.AccountID)
log.Printf(" Name: %s → %s", device.Name, liveInfo.Name)
log.Printf(" IP: %s", device.IPAddress)
log.Printf(" Serial: %s → %s", device.DeviceSerialNumber, liveInfo.SerialNumber)
log.Printf(" MAC: %s → %s", device.MacAddress, liveInfo.GetPrimaryMacAddress())
log.Printf(" Product: %s → %s %s", device.ProductCode, liveInfo.Type, liveInfo.ModuleType)
reasons := d.getMatchReasons(device, liveInfo)
for _, reason := range reasons {
log.Printf(" ✅ %s", reason)
}
}
// getMatchReason gets the primary reason for a match
func (d *DeviceMigrationDiagnostic) getMatchReason(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) string {
reasons := d.getMatchReasons(device, liveInfo)
if len(reasons) > 0 {
return reasons[0]
}
return "Unknown match reason"
}
// getMatchReasons gets all reasons why a device matches
func (d *DeviceMigrationDiagnostic) getMatchReasons(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) []string {
var reasons []string
// Serial number matches
if liveInfo.SerialNumber != "" && device.DeviceSerialNumber == liveInfo.SerialNumber {
reasons = append(reasons, "Device serial number matches")
}
if liveInfo.SerialNumber != "" && device.DeviceID == liveInfo.SerialNumber {
reasons = append(reasons, "DeviceID matches component serial")
}
// MAC address matches
primaryMAC := liveInfo.GetPrimaryMacAddress()
if primaryMAC != "" && device.MacAddress == primaryMAC {
reasons = append(reasons, "MAC address matches")
}
if device.DeviceID == liveInfo.DeviceID {
reasons = append(reasons, "DeviceID matches (already migrated)")
}
// Name and product
if liveInfo.Name != "" && device.Name == liveInfo.Name {
expectedProduct := liveInfo.Type + " " + liveInfo.ModuleType
if device.ProductCode == expectedProduct || device.ProductCode == liveInfo.Type {
reasons = append(reasons, "Name and product match exactly")
} else if strings.Contains(device.ProductCode, liveInfo.Type) || strings.Contains(expectedProduct, device.ProductCode) {
reasons = append(reasons, "Name and product similar")
}
}
// Component serials
for _, comp := range liveInfo.Components {
if comp.SerialNumber != "" && device.ProductSerialNumber == comp.SerialNumber {
reasons = append(reasons, fmt.Sprintf("Product serial matches %s component", comp.Category))
}
}
return reasons
}
// SimulateFullMigration simulates what would happen if migration ran for this device
func (d *DeviceMigrationDiagnostic) SimulateFullMigration(deviceIP string) error {
log.Printf("=== Migration Simulation for %s ===", deviceIP)
// Fetch device info
liveInfo, err := d.server.sm.GetLiveDeviceInfo(deviceIP)
if err != nil {
return fmt.Errorf("cannot fetch device info: %w", err)
}
// Simulate discovery
discovery := models.DiscoveredDevice{
Host: deviceIP,
Name: "Simulated Discovery",
SerialNo: "",
DiscoveryMethod: "Manual",
}
log.Printf("Target Device ID: %s", liveInfo.DeviceID)
log.Printf("Target Account: %s", liveInfo.MargeAccountUUID)
// Find existing variants
existingDevices := d.server.findAllExistingDeviceVariants(discovery, liveInfo)
if len(existingDevices) == 0 {
log.Printf("✨ This would be a NEW device:")
log.Printf(" Directory: accounts/%s/devices/%s/", liveInfo.MargeAccountUUID, liveInfo.DeviceID)
} else {
log.Printf("🔄 This would MIGRATE %d existing device(s):", len(existingDevices))
for i := range existingDevices {
existing := &existingDevices[i]
if existing.DeviceID != liveInfo.DeviceID {
log.Printf(" %s → %s", existing.DeviceID, liveInfo.DeviceID)
log.Printf(" From: accounts/%s/devices/%s/", existing.AccountID, existing.DeviceID)
log.Printf(" To: accounts/%s/devices/%s/", liveInfo.MargeAccountUUID, liveInfo.DeviceID)
} else {
log.Printf(" %s (already correct)", existing.DeviceID)
}
}
}
log.Printf("=== End Simulation ===")
return nil
}
// AnalyzeExistingDevices provides an overview of all devices and potential migration issues
func (d *DeviceMigrationDiagnostic) AnalyzeExistingDevices() error {
log.Printf("=== Device Migration Analysis ===")
allDevices, err := d.server.ds.ListAllDevices()
if err != nil {
return fmt.Errorf("failed to list devices: %w", err)
}
log.Printf("📊 Total devices in datastore: %d", len(allDevices))
// Categorize devices
var (
macBasedDevices []models.ServiceDeviceInfo
ipBasedDevices []models.ServiceDeviceInfo
serialBasedDevices []models.ServiceDeviceInfo
unknownDevices []models.ServiceDeviceInfo
)
for i := range allDevices {
device := &allDevices[i]
deviceID := device.DeviceID
switch {
case isMACAddress(deviceID):
macBasedDevices = append(macBasedDevices, *device)
case isIPAddress(deviceID):
ipBasedDevices = append(ipBasedDevices, *device)
case isSerialNumber(deviceID):
serialBasedDevices = append(serialBasedDevices, *device)
default:
unknownDevices = append(unknownDevices, *device)
}
}
log.Printf("\n📋 Device ID Categories:")
log.Printf(" ✅ MAC-based: %d (target format)", len(macBasedDevices))
log.Printf(" 🔄 IP-based: %d (needs migration)", len(ipBasedDevices))
log.Printf(" 🔄 Serial-based: %d (needs migration)", len(serialBasedDevices))
log.Printf(" ❓ Unknown format: %d", len(unknownDevices))
if len(ipBasedDevices) > 0 {
log.Printf("\n🔄 IP-based devices (migration candidates):")
for i := range ipBasedDevices {
device := &ipBasedDevices[i]
log.Printf(" %s (%s)", device.DeviceID, device.Name)
}
}
if len(serialBasedDevices) > 0 {
log.Printf("\n🔄 Serial-based devices (migration candidates):")
for i := range serialBasedDevices {
device := &serialBasedDevices[i]
log.Printf(" %s (%s)", device.DeviceID, device.Name)
}
}
if len(unknownDevices) > 0 {
log.Printf("\n❓ Unknown format devices:")
for i := range unknownDevices {
device := &unknownDevices[i]
log.Printf(" %s (%s)", device.DeviceID, device.Name)
}
}
log.Printf("=== End Analysis ===")
return nil
}
// Helper functions
func isMACAddress(s string) bool {
// AABBCCDDEEFF format
if len(s) == 12 {
return isHexOnly(s)
}
// AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF format
if len(s) == 17 && (strings.Contains(s, ":") || strings.Contains(s, "-")) {
s = strings.ReplaceAll(s, "-", ":")
parts := strings.Split(s, ":")
if len(parts) != 6 {
return false
}
for _, part := range parts {
if len(part) != 2 || !isHexOnly(part) {
return false
}
}
return true
}
return false
}
func isHexOnly(s string) bool {
for _, r := range s {
if (r < '0' || r > '9') && (r < 'A' || r > 'F') && (r < 'a' || r > 'f') {
return false
}
}
return true
}
func isIPAddress(s string) bool {
parts := strings.Split(s, ".")
if len(parts) != 4 {
return false
}
for _, part := range parts {
if len(part) == 0 || len(part) > 3 {
return false
}
for _, r := range part {
if r < '0' || r > '9' {
return false
}
}
}
return true
}
func isSerialNumber(s string) bool {
// Heuristic: serial numbers are typically alphanumeric and longer than MAC addresses
if len(s) < 10 || len(s) > 30 {
return false
}
hasLetter := false
hasDigit := false
for _, r := range s {
switch {
case (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'):
hasLetter = true
case r >= '0' && r <= '9':
hasDigit = true
default:
return false // Contains non-alphanumeric characters
}
}
return hasLetter && hasDigit
}
@@ -0,0 +1,53 @@
package handlers
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestMirrorMiddleware_InfiniteLoop(t *testing.T) {
tempDir, err := os.MkdirTemp("", "mirror-loop-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
server := NewServer(ds, nil, "http://localhost:8000", false, false, false)
server.SetMirrorSettings(true, []string{"/loop"}, nil, "upstream")
// Create a handler that would be the "next" in the chain.
// If the loop occurs, this will be called repeatedly.
callCount := 0
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
middleware := server.MirrorMiddleware(handler)
// Simulate a mirror request by adding the X-Mirror-Request header.
// This is what performMirror adds to the proxied request.
req := httptest.NewRequest("GET", "/loop", nil)
req.Header.Set("X-Mirror-Request", "true")
w := httptest.NewRecorder()
middleware.ServeHTTP(w, req)
// If the fix is working, the middleware should see X-Mirror-Request and
// pass directly to the handler WITHOUT trying to mirror again.
if callCount != 1 {
t.Errorf("Expected 1 call to handler, got %d", callCount)
}
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
}
+94 -7
View File
@@ -5,6 +5,7 @@ import (
"context"
"crypto/tls"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"log"
@@ -17,14 +18,18 @@ import (
"sort"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
)
// MirrorMiddleware returns a middleware that mirrors specific requests to the Bose upstream.
func (s *Server) MirrorMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
enabled, endpoints, preferredSource := s.getMirrorSettings()
enabled, endpoints, skipEndpoints, preferredSource := s.getMirrorSettings()
isMirrorRequest := r.Header.Get("X-Mirror-Request") == "true"
if !enabled || len(endpoints) == 0 || !s.shouldMirror(r.URL.Path, endpoints) {
if !enabled || isMirrorRequest || len(endpoints) == 0 || !s.shouldMirror(r.URL.Path, endpoints) || s.shouldSkipMirror(r.URL.Path, skipEndpoints) {
next.ServeHTTP(w, r)
return
}
@@ -59,11 +64,11 @@ func (s *Server) MirrorMiddleware(next http.Handler) http.Handler {
})
}
func (s *Server) getMirrorSettings() (bool, []string, string) {
func (s *Server) getMirrorSettings() (bool, []string, []string, string) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.mirrorEnabled, s.mirrorEndpoints, s.preferredSource
return s.mirrorEnabled, s.mirrorEndpoints, s.skipMirrorEndpoints, s.preferredSource
}
func (s *Server) shouldMirror(path string, endpoints []string) bool {
@@ -76,6 +81,16 @@ func (s *Server) shouldMirror(path string, endpoints []string) bool {
return false
}
func (s *Server) shouldSkipMirror(path string, skipEndpoints []string) bool {
for _, pattern := range skipEndpoints {
if matchPattern(pattern, path) {
return true
}
}
return false
}
func (s *Server) mirrorUpstreamPreferred(detachedCtx context.Context, w http.ResponseWriter, r *http.Request, next http.Handler, bodyBytes []byte) {
log.Printf("[MIRROR] Upstream is preferred source for %s %s", r.Method, r.URL.Path)
@@ -322,20 +337,92 @@ func (s *Server) checkParity(req *http.Request, local, upstream *mirrorResponseR
reasons = append(reasons, fmt.Sprintf("Content-Type mismatch: local %s, upstream %s", localCT, upstreamCT))
}
// Basic body comparison (could be improved with XML semantic diff)
// Compare bodies
localBody := local.body.Bytes()
upstreamBody := upstream.body.Bytes()
if !bytes.Equal(localBody, upstreamBody) {
mismatch = true
// If both are XML, try a whitespace-insensitive comparison
isXML := (strings.Contains(localCT, "/xml") || strings.Contains(localCT, "+xml")) &&
(strings.Contains(upstreamCT, "/xml") || strings.Contains(upstreamCT, "+xml"))
reasons = append(reasons, "Body content mismatch")
if isXML {
if !s.compareXMLWhitespaceInsensitive(localBody, upstreamBody) {
mismatch = true
reasons = append(reasons, "Body content mismatch (XML)")
}
} else {
mismatch = true
reasons = append(reasons, "Body content mismatch")
}
}
if mismatch {
log.Printf("[PARITY] Mismatch detected for %s %s: %v", req.Method, req.URL.Path, reasons)
s.saveParityMismatch(req, local, upstream, reasons)
}
// Trigger synchronization if this is a /full response from upstream
if strings.Contains(req.URL.Path, "/full") && upstream.status == http.StatusOK {
var resp models.AccountFullResponse
if err := xml.Unmarshal(upstream.body.Bytes(), &resp); err == nil {
log.Printf("[MIRROR] Triggering sync from upstream /full response for %s", req.URL.Path)
marge.LogSyncDiff(s.ds, &resp)
if err = marge.SyncFromAccountFull(s.ds, &resp); err != nil {
log.Printf("[MIRROR_ERR] Failed to sync from upstream /full: %v", err)
}
} else {
log.Printf("[MIRROR_ERR] Failed to unmarshal upstream /full response: %v", err)
}
}
}
// compareXMLWhitespaceInsensitive compares two XML bodies ignoring whitespace between elements.
func (s *Server) compareXMLWhitespaceInsensitive(local, upstream []byte) bool {
clean := func(b []byte) string {
s := string(b)
// Remove XML declaration for easier comparison
if strings.HasPrefix(s, "<?xml") {
if idx := strings.Index(s, "?>"); idx != -1 {
s = s[idx+2:]
}
}
// Normalize whitespace:
// 1. Remove all whitespace between elements (i.e., between > and <)
// 2. Trim surrounding whitespace
var result strings.Builder
inTag := false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '<':
inTag = true
result.WriteByte(c)
case c == '>':
inTag = false
result.WriteByte(c)
case inTag:
result.WriteByte(c)
default:
// We are between tags, only add if not whitespace
if c != ' ' && c != '\n' && c != '\r' && c != '\t' {
result.WriteByte(c)
}
}
}
return strings.TrimSpace(result.String())
}
return clean(local) == clean(upstream)
}
func (s *Server) saveParityMismatch(req *http.Request, local, upstream *mirrorResponseRecorder, reasons []string) {
@@ -39,8 +39,8 @@ func TestMirrorMiddleware_PreferredSource(t *testing.T) {
defer upstreamServer.Close()
// 3. Setup our server with MirrorMiddleware
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false, false)
server.SetMirrorSettings(true, []string{"/test/local"}, "local")
server := NewServer(ds, nil, "http://localhost:8000", false, false, false)
server.SetMirrorSettings(true, []string{"/test/local"}, nil, "local")
// We need to trick performMirror to use our mock upstream.
// performMirror uses r.Host.
@@ -50,7 +50,7 @@ func TestMirrorMiddleware_PreferredSource(t *testing.T) {
middleware := server.MirrorMiddleware(r)
t.Run("PreferredLocal", func(t *testing.T) {
server.SetMirrorSettings(true, []string{"/test/local"}, "local")
server.SetMirrorSettings(true, []string{"/test/local"}, nil, "local")
req := httptest.NewRequest("GET", "/test/local", nil)
req.Host = upstreamHost // So performMirror targets the mock upstream
@@ -70,7 +70,7 @@ func TestMirrorMiddleware_PreferredSource(t *testing.T) {
})
t.Run("PreferredUpstream", func(t *testing.T) {
server.SetMirrorSettings(true, []string{"/test/local"}, "upstream")
server.SetMirrorSettings(true, []string{"/test/local"}, nil, "upstream")
req := httptest.NewRequest("GET", "/test/local", nil)
req.Host = upstreamHost
@@ -90,7 +90,7 @@ func TestMirrorMiddleware_PreferredSource(t *testing.T) {
})
t.Run("FallbackToLocal", func(t *testing.T) {
server.SetMirrorSettings(true, []string{"/test/local"}, "upstream")
server.SetMirrorSettings(true, []string{"/test/local"}, nil, "upstream")
// Use a non-existent host for mirror to trigger failure
req := httptest.NewRequest("GET", "/test/local", nil)
@@ -119,7 +119,7 @@ func TestSettingsAPI_PreferredSource(t *testing.T) {
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false, false)
server := NewServer(ds, nil, "http://localhost:8000", false, false, false)
// Test GET initial
req := httptest.NewRequest("GET", "/setup/settings", nil)
+2 -2
View File
@@ -45,7 +45,7 @@ func TestMirroring(t *testing.T) {
recorder := proxy.NewRecorder(tempDir)
server.SetRecorder(recorder)
server.SetRecordEnabled(true)
server.SetMirrorSettings(true, []string{"/streaming/account/*/device/*/recent"}, "local")
server.SetMirrorSettings(true, []string{"/streaming/account/*/device/*/recent"}, nil, "local")
ts := httptest.NewServer(r)
defer ts.Close()
@@ -167,7 +167,7 @@ func TestMirroring(t *testing.T) {
defer postUpstream.Close()
// Setup mirroring for the POST endpoint
server.SetMirrorSettings(true, []string{"/v1/scmudc/*"}, "local")
server.SetMirrorSettings(true, []string{"/v1/scmudc/*"}, nil, "local")
requestBody := `{"envelope":{"monoTime":234906,"payloadProtocolVersion":"3.1","payloadType":"scmudc","protocolVersion":"1.0","time":"2026-02-25T23:03:14.976349+00:00","uniqueId":"A81B6A536A98"},"payload":{"deviceInfo":{"boseID":"3230304","deviceID":"A81B6A536A98","deviceType":"SoundTouch 10","serialNumber":"I6332527703739342000020","softwareVersion":"27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29","systemSerialNumber":"069231P63364828AE"},"events":[{"data":{"play-state":"PAUSE_STATE"},"monoTime":234904,"time":"2026-02-25T23:03:14.973466+00:00","type":"play-state-changed"}]}}`
@@ -0,0 +1,109 @@
package handlers
import (
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestParityMismatchReproduction_New(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-parity-reproduce-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "1234567"
deviceID := "001122334455"
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
// Upstream example payload for POST /recent
payload := `
<recent>
<contentItemType>stationurl</contentItemType>
<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>
<location>/v1/playback/station/s104811</location>
<name>1LIVE Chillout</name>
<source id="14774275" type="Audio">
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
<credential type="token">dummy-token-base64</credential>
<name></name>
<sourceproviderid>25</sourceproviderid>
<sourcename></sourcename>
<sourceSettings/>
<updatedOn>2017-07-20T16:43:48.000+00:00</updatedOn>
<username></username>
</source>
<sourceid>14774275</sourceid>
<updatedOn>2026-03-14T12:50:14.221+00:00</updatedOn>
</recent>`
t.Run("POST /recent should learn source details and respond with parity", func(t *testing.T) {
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
t.Fatalf("Expected status 201, got %d", res.StatusCode)
}
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
t.Logf("Response Body:\n%s\n", bodyStr)
// Verification points:
// 1. Standalone="yes"
if !strings.Contains(bodyStr, `standalone="yes"`) {
t.Errorf("Missing standalone=\"yes\"")
}
// 2. Millisecond precision in dates
if !strings.Contains(bodyStr, ".000+00:00") && !strings.Contains(bodyStr, ".221+00:00") {
// Note: FormatTime always uses .000+00:00 for now, but it's acceptable.
// The key is it MUST have milliseconds and +00:00 offset.
t.Errorf("Date format mismatch, expected .000+00:00. Body: %s", bodyStr)
}
// 3. SourceProviderID learned (25)
if !strings.Contains(bodyStr, `sourceproviderid="25"`) {
t.Errorf("SourceProviderID was not learned from POST, expected 25 in attribute. Body: %s", bodyStr)
}
// 4. Credential learned
if !strings.Contains(bodyStr, `secret="dummy-token-base64"`) {
t.Errorf("Secret was not learned from POST in attribute. Body: %s", bodyStr)
}
// 6. Source CreatedOn/UpdatedOn learned
if !strings.Contains(bodyStr, `createdOn="2017-07-20T16:43:48.000+00:00"`) {
t.Errorf("Source CreatedOn was not learned from POST in attribute. Body: %s", bodyStr)
}
})
t.Run("Subsequent GET /recents should also show learned source details", func(t *testing.T) {
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/recent")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, `sourceproviderid="25"`) {
t.Errorf("GET /recents missing learned sourceproviderid 25 in attribute. Body: %s", bodyStr)
}
})
}
@@ -0,0 +1,83 @@
package handlers
import (
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestParityMismatchReproduction_V2(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-parity-repro-v2-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "1234567"
deviceID := "001122334455"
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("POST /recent parity with upstream example", func(t *testing.T) {
payload := `
<recent>
<contentItemType>stationurl</contentItemType>
<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>
<location>/v1/playback/station/s104811</location>
<name>1LIVE Chillout</name>
<source id="14774275" type="Audio">
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
<credential type="token">dummy-token-base64</credential>
<name></name>
<sourceproviderid>25</sourceproviderid>
<sourcename></sourcename>
<sourceSettings></sourceSettings>
<updatedOn>2017-07-20T16:43:48.000+00:00</updatedOn>
<username></username>
</source>
<sourceid>14774275</sourceid>
</recent>`
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
if !strings.HasPrefix(bodyStr, constants.XMLHeader) {
t.Errorf("Missing or incorrect XML declaration: %s", bodyStr)
}
if !strings.Contains(bodyStr, `id="`) {
t.Errorf("Missing recent id attribute")
}
if !strings.Contains(bodyStr, ".000+00:00") {
t.Errorf("Date format mismatch. Expected .000+00:00. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, `sourceproviderid="25"`) {
t.Errorf("sourceproviderid mismatch. Expected 25 in attribute. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, `secret="dummy-token-base64"`) {
t.Errorf("Secret value mismatch in attribute. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, "<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>") {
t.Errorf("lastplayedat mismatch. Body: %s", bodyStr)
}
})
}
@@ -0,0 +1,133 @@
package handlers
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestParityMismatchReproduction_V3(t *testing.T) {
tempDir, _ := os.MkdirTemp("", "marge-test")
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
// Upstream input for POST /recent (extracted from user description)
// We'll use the same source metadata as provided in the upstream response
// to see if we can "learn" it and echo it back correctly.
requestBody := `
<recent>
<contentItemType>stationurl</contentItemType>
<location>/v1/playback/station/s104811</location>
<name>1LIVE Chillout</name>
<source id="14774275" type="Audio">
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
<credential type="token">dummy-token-base64</credential>
<sourceproviderid>25</sourceproviderid>
<sourcename></sourcename>
<sourceSettings/>
<updatedOn>2017-07-20T16:43:48.000+00:00</updatedOn>
</source>
<sourceid>14774275</sourceid>
</recent>`
account := "1234567"
device := "001122334455"
url := fmt.Sprintf("%s/streaming/account/%s/device/%s/recent", ts.URL, account, device)
t.Run("POST /recent and check parity", func(t *testing.T) {
res, err := http.Post(url, "application/xml", strings.NewReader(requestBody))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
t.Errorf("Expected status 201, got %v", res.Status)
}
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
t.Logf("POST /recent Response:\n%s\n", bodyStr)
if !strings.Contains(bodyStr, constants.XMLHeader) {
t.Error("Missing XML declaration with standalone=\"yes\"")
}
// 2. Large ID (YYMMDDxxx format)
prefix := time.Now().UTC().Format("060102")
if !strings.Contains(bodyStr, fmt.Sprintf(`id="%s`, prefix)) {
t.Errorf("Recent ID missing expected prefix %s. Body: %s", prefix, bodyStr)
}
// 3. Date Formatting (.000+00:00)
if !strings.Contains(bodyStr, `.000+00:00`) {
t.Error("Dates are missing milliseconds or incorrect offset")
}
// 4. Source Learning
// Check for provider ID 25
if !strings.Contains(bodyStr, `sourceproviderid="25"`) {
t.Errorf("Source provider ID mismatch: expected 25 for TuneIn in attribute. Body: %s", bodyStr)
}
// Check for credential
if !strings.Contains(bodyStr, `secret="dummy-token-base64"`) {
t.Errorf("Secret value was not preserved in attribute. Body: %s", bodyStr)
}
// 6. Indentation check (2 spaces)
if !strings.Contains(bodyStr, "\n <contentItem source=\"TUNEIN\"") {
t.Errorf("Incorrect indentation for contentItem: expected 2 spaces. Body: %s", bodyStr)
}
})
t.Run("Verify GET /recents consistency", func(t *testing.T) {
recentsUrl := fmt.Sprintf("%s/streaming/account/%s/device/%s/recent", ts.URL, account, device)
res, err := http.Get(recentsUrl)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
t.Logf("GET /recents Local Response:\n%s\n", bodyStr)
if !strings.Contains(bodyStr, `sourceproviderid="25"`) {
t.Error("Source provider ID missing in GET /recents")
}
})
}
func TestXMLWhitespaceInsensitivity(t *testing.T) {
s := &Server{}
local := []byte(constants.XMLHeader + `
<recent id="123">
<name>Test</name>
</recent>`)
upstream := []byte(constants.XMLHeader + `
<recent id="123">
<name>Test</name>
</recent>`)
if !s.compareXMLWhitespaceInsensitive(local, upstream) {
t.Error("compareXMLWhitespaceInsensitive failed for simple whitespace difference")
}
upstreamNoSpaces := []byte(constants.XMLHeader + `<recent id="123"><name>Test</name></recent>`)
if !s.compareXMLWhitespaceInsensitive(local, upstreamNoSpaces) {
t.Error("compareXMLWhitespaceInsensitive failed for no-whitespace upstream")
}
}
@@ -0,0 +1,105 @@
package handlers
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestMargeParityRegressions(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-parity-regressions-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "1234567"
deviceID := "001122334455"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
os.MkdirAll(deviceDir, 0755)
// Mock Sources.xml matching the upstream example (source id 14774275)
// One with "Other" and one with a specific name.
sourcesXML := `
<sources>
<source id="14774275" displayName="Other" secret="" secretType="Audio">
<sourceKey type="TUNEIN" account=""/>
</source>
<source id="SPOT1" displayName="My Spotify" secret="token123" secretType="Audio">
<sourceKey type="SPOTIFY" account="user123"/>
</source>
</sources>`
os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644)
os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte("<recents></recents>"), 0644)
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("POST recent with Other source - displayName should be 'Other' in attribute", func(t *testing.T) {
payload := `
<recent>
<contentItemType>stationurl</contentItemType>
<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>
<location>/v1/playback/station/s104811</location>
<name>1LIVE Chillout</name>
<sourceid>14774275</sourceid>
</recent>`
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
// Check for standalone="yes"
if !strings.Contains(bodyStr, `standalone="yes"`) {
t.Errorf("Response missing standalone=\"yes\"")
}
// Check for displayName when it's "Other"
if !strings.Contains(bodyStr, `displayName="Other"`) {
t.Errorf("Expected displayName=\"Other\", but got: %s", bodyStr)
}
// Check for date format (should have .000+00:00)
if !strings.Contains(bodyStr, ".000+00:00") {
t.Errorf("Response date format mismatch, expected .000+00:00. Body: %s", bodyStr)
}
})
t.Run("POST recent with named source - displayName should be preserved in attribute", func(t *testing.T) {
payload := `
<recent>
<contentItemType>track</contentItemType>
<lastplayedat>2026-03-14T12:50:10.000+00:00</lastplayedat>
<location>spotify:track:123</location>
<name>Test Song</name>
<sourceid>SPOT1</sourceid>
</recent>`
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, `displayName="My Spotify"`) {
t.Errorf("Expected displayName=\"My Spotify\", body: %s", bodyStr)
}
})
}
+143
View File
@@ -0,0 +1,143 @@
package handlers
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestMargeRecentConsistencyAndIDParity(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-recent-parity-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "1234567"
deviceID := "001122334455"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
os.MkdirAll(deviceDir, 0755)
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("POST recent creates consistent IDs and persists unknown sources", func(t *testing.T) {
payload := `
<recent>
<contentItemType>tracklisturl</contentItemType>
<lastplayedat>2026-03-14T21:33:22.000+00:00</lastplayedat>
<location>/playback/container/c3BvdGlmeTphbGJ1bTo3RjUwdWg3b0dpdG1BRVNjUktWNnBE</location>
<name>Terminal Caribe</name>
<sourceid>10863533</sourceid>
</recent>`
// 1. POST /recent
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
t.Fatalf("Expected status 201, got %d", res.StatusCode)
}
postBody, _ := io.ReadAll(res.Body)
postBodyStr := string(postBody)
// Verify ID format: YYMMDDXXX (9 digits)
// Today's prefix:
prefix := time.Now().UTC().Format("060102")
idPattern := fmt.Sprintf(`id="%s`, prefix)
if !strings.Contains(postBodyStr, idPattern) {
t.Errorf("Response ID missing expected prefix %s. Body: %s", prefix, postBodyStr)
}
// Extract ID
startIdx := strings.Index(postBodyStr, `id="`) + 4
endIdx := strings.Index(postBodyStr[startIdx:], `"`) + startIdx
recentID := postBodyStr[startIdx:endIdx]
idInt, err := strconv.Atoi(recentID)
if err != nil {
t.Errorf("Recent ID is not an integer: %s", recentID)
} else if idInt > 2147483647 {
t.Errorf("Recent ID exceeds 32-bit signed integer range: %d", idInt)
}
// 2. GET /recents
res2, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/recent")
if err != nil {
t.Fatal(err)
}
defer res2.Body.Close()
getRecentsBody, _ := io.ReadAll(res2.Body)
getRecentsStr := string(getRecentsBody)
// 3. Verify consistency
// Use a whitespace-insensitive comparison
clean := func(s string) string {
if strings.HasPrefix(s, "<?xml") {
if idx := strings.Index(s, "?>"); idx != -1 {
s = s[idx+2:]
}
}
var result strings.Builder
inTag := false
for i := 0; i < len(s); i++ {
c := s[i]
if c == '<' {
inTag = true
result.WriteByte(c)
} else if c == '>' {
inTag = false
result.WriteByte(c)
} else if inTag {
result.WriteByte(c)
} else {
if c != ' ' && c != '\n' && c != '\r' && c != '\t' {
result.WriteByte(c)
}
}
}
return strings.TrimSpace(result.String())
}
if !strings.Contains(clean(getRecentsStr), clean(postBodyStr)) {
t.Errorf("GET /recents does not contain the same XML as POST /recent response.\nPOST: %s\nGET: %s", postBodyStr, getRecentsStr)
}
// 4. Verify source persistence
// Check if source 10863533 was learned and is now in Sources.xml
sources, err := ds.GetConfiguredSources(account, deviceID)
if err != nil {
t.Errorf("Failed to get configured sources: %v", err)
}
found := false
for _, s := range sources {
if s.ID == "10863533" {
found = true
if s.SourceKeyType != "SPOTIFY" {
t.Errorf("Learned source should be SPOTIFY based on location, got %s", s.SourceKeyType)
}
break
}
}
if !found {
t.Errorf("Source 10863533 was not learned and persisted")
}
})
}
+4 -20
View File
@@ -5,7 +5,6 @@ import (
"bytes"
"fmt"
"io"
"log"
"net"
"net/http"
)
@@ -13,7 +12,7 @@ import (
// RecordMiddleware returns a middleware that records "self" requests and responses.
func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.recorder == nil || !s.recordEnabled || r.Header.Get("Upgrade") == "websocket" {
if s.recorder == nil || !s.recordEnabled {
next.ServeHTTP(w, r)
return
}
@@ -53,10 +52,6 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
// Create a response object for the recorder
res := rw.getRecordedResponse(r)
if res.StatusCode >= 400 {
log.Printf("[DEBUG_LOG] Recording error response: %d %s %s", res.StatusCode, r.Method, r.URL.Path)
}
if res.Body != nil {
defer func() { _ = res.Body.Close() }()
}
@@ -70,9 +65,8 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
type responseWriter struct {
http.ResponseWriter
statusCode int
body *bytes.Buffer
wroteHeader bool
statusCode int
body *bytes.Buffer
}
func (rw *responseWriter) Header() http.Header {
@@ -80,28 +74,18 @@ func (rw *responseWriter) Header() http.Header {
}
func (rw *responseWriter) WriteHeader(code int) {
if rw.wroteHeader {
return
}
rw.statusCode = code
rw.wroteHeader = true
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
if !rw.wroteHeader {
rw.WriteHeader(http.StatusOK)
}
rw.body.Write(b)
return rw.ResponseWriter.Write(b)
}
func (rw *responseWriter) getRecordedResponse(r *http.Request) *http.Response {
statusCode := rw.statusCode
if !rw.wroteHeader && statusCode == 0 {
if statusCode == 0 {
statusCode = http.StatusOK
}
+5 -143
View File
@@ -16,7 +16,6 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/migration"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
@@ -27,7 +26,6 @@ import (
type Server struct {
ds *datastore.DataStore
sm *setup.Manager
migrationManager *migration.Manager
mu sync.RWMutex
serverURL string
httpsServerURL string
@@ -42,6 +40,7 @@ type Server struct {
dnsBindAddr string
mirrorEnabled bool
mirrorEndpoints []string
skipMirrorEndpoints []string
preferredSource string
internalPaths []string
shortcuts map[string]int
@@ -81,17 +80,10 @@ var bufferPool = sync.Pool{
}
// NewServer creates a new SoundTouch service server.
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled, migrationEnabled, migrationDryRun bool) *Server {
// Initialize migration manager
migrationConfig := migration.Config{
Enabled: migrationEnabled,
DryRun: migrationDryRun,
}
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled bool) *Server {
s := &Server{
ds: ds,
sm: sm,
migrationManager: migration.NewManager(ds, migrationConfig),
serverURL: serverURL,
proxyRedact: proxyRedact,
proxyLogBody: proxyLogBody,
@@ -328,12 +320,13 @@ func (s *Server) SetMgmtConfig(username, password string) {
}
// SetMirrorSettings sets the mirroring settings for the server.
func (s *Server) SetMirrorSettings(enabled bool, endpoints []string, preferredSource string) {
func (s *Server) SetMirrorSettings(enabled bool, endpoints, skipEndpoints []string, preferredSource string) {
s.mu.Lock()
defer s.mu.Unlock()
s.mirrorEnabled = enabled
s.mirrorEndpoints = endpoints
s.skipMirrorEndpoints = skipEndpoints
s.preferredSource = preferredSource
}
@@ -568,21 +561,7 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
}
}
// 7. Check for existing device entries that need migration
log.Printf("Checking for existing device variants to migrate for device %s (MAC: %s)", liveInfo.Name, deviceID)
existingDevices := s.findAllExistingDeviceVariants(d, liveInfo)
if len(existingDevices) == 0 {
log.Printf("No existing device variants found for migration")
}
// Use migration manager to handle device directory migration
migrated := s.migrationManager.MigrateDevicesIfNeeded(existingDevices, deviceID)
if !migrated {
log.Printf("Device %s: no migration needed (already uses correct MAC-based ID %s)", liveInfo.Name, deviceID)
}
// 8. Save the updated device info
// 7. Save the updated device info
if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil {
log.Printf("Failed to save device info for %s: %v", deviceID, err)
return
@@ -591,11 +570,6 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
log.Printf("Successfully saved device %s (%s) with MAC-based deviceID: %s", info.Name, d.Host, deviceID)
}
// GetMigrationStats returns migration statistics for debugging/monitoring
func (s *Server) GetMigrationStats() migration.Stats {
return s.migrationManager.GetStats()
}
// handleDiscoveredDeviceFallback handles device discovery when /info endpoint is not available
func (s *Server) handleDiscoveredDeviceFallback(d models.DiscoveredDevice) {
log.Printf("Using fallback discovery method for device: %s at %s", d.Name, d.Host)
@@ -715,98 +689,6 @@ func (s *Server) findExistingDeviceID(d models.DiscoveredDevice) string {
return ""
}
// findAllExistingDeviceVariants finds all existing device entries that could represent the same physical device
func (s *Server) findAllExistingDeviceVariants(d models.DiscoveredDevice, liveInfo *setup.DeviceInfoXML) []models.ServiceDeviceInfo {
// log.Printf("Searching for existing device variants with criteria:")
// log.Printf(" Discovery IP: %s", d.Host)
// log.Printf(" Discovery Serial: %s", d.SerialNo)
// log.Printf(" Live Info Serial: %s", liveInfo.SerialNumber)
// log.Printf(" Live Info Name: %s", liveInfo.Name)
// log.Printf(" Live Info MAC: %s", liveInfo.GetPrimaryMacAddress())
// log.Printf(" Live Info Product: %s %s", liveInfo.Type, liveInfo.ModuleType)
allDevices, err := s.ds.ListAllDevices()
if err != nil {
return nil
}
var matches []models.ServiceDeviceInfo
seenDeviceIDs := make(map[string]bool)
for i := range allDevices {
device := &allDevices[i]
if seenDeviceIDs[device.DeviceID] {
continue
}
matchReason := s.getMatchReason(*device, d, liveInfo)
if matchReason != "" {
matches = append(matches, *device)
seenDeviceIDs[device.DeviceID] = true
log.Printf(" ✓ Found variant %s: %s", device.DeviceID, matchReason)
}
}
if len(matches) == 0 {
log.Printf(" No existing device variants found")
} else {
log.Printf("Found %d existing device variant(s) for %s:", len(matches), liveInfo.Name)
for i := range matches {
match := &matches[i]
log.Printf(" - %s (Account: %s, IP: %s, Serial: %s, MAC: %s, Product: %s)",
match.DeviceID, match.AccountID, match.IPAddress, match.DeviceSerialNumber, match.MacAddress, match.ProductCode)
}
}
return matches
}
func (s *Server) getMatchReason(device models.ServiceDeviceInfo, d models.DiscoveredDevice, liveInfo *setup.DeviceInfoXML) string {
// 1. Same IP address
if d.Host != "" && device.IPAddress == d.Host {
return fmt.Sprintf("IP address match (%s == %s)", d.Host, device.IPAddress)
}
// 2. Same UPnP serial number
if d.SerialNo != "" && (device.DeviceID == d.SerialNo || device.DeviceSerialNumber == d.SerialNo) {
if device.DeviceID == d.SerialNo {
return "UPnP serial as DeviceID"
}
return "UPnP serial in DeviceSerialNumber"
}
// 3. Same device serial number from /info
if liveInfo.SerialNumber != "" && device.DeviceSerialNumber == liveInfo.SerialNumber {
return fmt.Sprintf("device serial number match (%s)", liveInfo.SerialNumber)
}
// 4. Same MAC address (if device already has one stored)
primaryMAC := liveInfo.GetPrimaryMacAddress()
if primaryMAC != "" && device.MacAddress == primaryMAC {
return fmt.Sprintf("MAC address match (%s)", primaryMAC)
}
// 5. Same device name and similar product (fuzzy match for renamed devices)
if liveInfo.Name != "" && device.Name == liveInfo.Name {
expectedProduct := liveInfo.Type + " " + liveInfo.ModuleType
if device.ProductCode == expectedProduct ||
device.ProductCode == liveInfo.Type ||
strings.Contains(device.ProductCode, liveInfo.Type) ||
strings.Contains(expectedProduct, device.ProductCode) {
return fmt.Sprintf("name and product match (name: %s, product: %s)", liveInfo.Name, device.ProductCode)
}
}
// 6. DeviceID matches component serial (device was stored by serial before)
if liveInfo.SerialNumber != "" && device.DeviceID == liveInfo.SerialNumber {
return fmt.Sprintf("DeviceID matches component serial (%s)", liveInfo.SerialNumber)
}
return ""
}
func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.ServiceDeviceInfo {
allDevices, _ := s.ds.ListAllDevices()
for i := range allDevices {
@@ -840,23 +722,3 @@ func (s *Server) resolveDeviceIDToIP(deviceID string) (string, error) {
return "", fmt.Errorf("device not found: %s", deviceID)
}
// lookupIP resolves a deviceId to its last known device IP.
func (s *Server) lookupIP(deviceId string) (string, error) {
devices, err := s.ds.ListAllDevices()
if err != nil {
return "", err
}
for i := range devices {
if devices[i].DeviceID == deviceId {
if devices[i].IPAddress == "" {
return "", fmt.Errorf("no IP known for deviceId %s", deviceId)
}
return devices[i].IPAddress, nil
}
}
return "", fmt.Errorf("deviceId %s not found", deviceId)
}
+2 -2
View File
@@ -16,7 +16,7 @@ func TestMergeOverlappingDevices(t *testing.T) {
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
s := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
s := NewServer(ds, nil, "http://localhost", false, false, false)
// Case 1: IP-only entry and Serial-based entry for the same IP
ip := "192.168.1.100"
@@ -74,7 +74,7 @@ func TestFindExistingDeviceID(t *testing.T) {
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
s := NewServer(ds, nil, "http://localhost", false, false, false, false, false)
s := NewServer(ds, nil, "http://localhost", false, false, false)
ip := "192.168.1.101"
serial := "SERIAL456"
@@ -25,9 +25,9 @@ func TestSnapshotIntegrity_SelfAndMirror(t *testing.T) {
ds := datastore.NewDataStore(tempDir)
recorder := proxy.NewRecorder(tempDir)
s := NewServer(ds, nil, "http://localhost:8000", false, false, true, false, false)
s := NewServer(ds, nil, "http://localhost:8000", false, false, true)
s.SetRecorder(recorder)
s.SetMirrorSettings(true, []string{"/mirror/*"}, "local")
s.SetMirrorSettings(true, []string{"/mirror/*"}, nil, "local")
// Upstream mock
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -39,7 +39,7 @@ func TestSnapshotIntegrity_SelfAndMirror(t *testing.T) {
defer upstream.Close()
// Configure mirror to point to our mock upstream
s.SetMirrorSettings(true, []string{"/mirror/*"}, "local")
s.SetMirrorSettings(true, []string{"/mirror/*"}, nil, "local")
// We need to override the host in performMirror but for tests we can just mock it via env if needed or rely on the fact that performMirror uses r.Host
handler := s.SnapshotMiddleware(s.MirrorMiddleware(s.RecordMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+209 -209
View File
@@ -1,215 +1,215 @@
{
"_links": {
"bmx_services_availability": {
"href": "../servicesAvailability"
}
},
"askAgainAfter": 1230482,
"bmx_services": [
{
"_links": {
"bmx_navigate": {
"href": "/v1/navigate"
},
"bmx_token": {
"href": "/v1/token"
},
"self": {
"href": "/"
"_links": {
"bmx_services_availability": {
"href": "../servicesAvailability"
}
},
"askAdapter": false,
"assets": {
"color": "#000000",
"description": "With TuneIn on SoundTouch, listen to more than 100,000 stations and the hottest podcasts, plus live games, concerts and shows from around the world. However, you cannot access your Favorites and Premium content on your existing TuneIn account at this time.",
"icons": {
"defaultAlbumArt": "{MEDIA_SERVER}/tunein-default-album-art.png",
"largeSvg": "{MEDIA_SERVER}/tunein-smallSvg.svg",
"monochromePng": "{MEDIA_SERVER}/tunein-monochromePng.png",
"monochromeSvg": "{MEDIA_SERVER}/tunein-monochromeSvg.svg",
"smallSvg": "{MEDIA_SERVER}/tunein-smallSvg.svg"
},
"name": "TuneIn"
},
"authenticationModel": {
"anonymousAccount": {
"autoCreate": true,
"enabled": true
}
},
"baseUrl": "{BMX_SERVER}/bmx/tunein",
"id": {
"name": "TUNEIN",
"value": 25
},
"streamTypes": [
"liveRadio",
"onDemand"
]
},
{
"_links": {
"bmx_token": {
"href": "/token"
"askAgainAfter": 1230482,
"bmx_services": [
{
"_links": {
"bmx_navigate": {
"href": "/v1/navigate"
},
"bmx_token": {
"href": "/v1/token"
},
"self": {
"href": "/"
}
},
"askAdapter": false,
"assets": {
"color": "#000000",
"description": "With TuneIn on SoundTouch, listen to more than 100,000 stations and the hottest podcasts, plus live games, concerts and shows from around the world. However, you cannot access your Favorites and Premium content on your existing TuneIn account at this time.",
"icons": {
"defaultAlbumArt": "{MEDIA_SERVER}/bmx-icons/tunein/default-album-art.png",
"largeSvg": "{MEDIA_SERVER}/bmx-icons/tunein/smallSvg.svg",
"monochromePng": "{MEDIA_SERVER}/bmx-icons/tunein/monochromePng.png",
"monochromeSvg": "{MEDIA_SERVER}/bmx-icons/tunein/monochromeSvg.svg",
"smallSvg": "{MEDIA_SERVER}/bmx-icons/tunein/smallSvg.svg"
},
"name": "TuneIn"
},
"authenticationModel": {
"anonymousAccount": {
"autoCreate": true,
"enabled": true
}
},
"baseUrl": "{BMX_SERVER}/bmx/tunein",
"id": {
"name": "TUNEIN",
"value": 25
},
"streamTypes": [
"liveRadio",
"onDemand"
]
},
"self": {
"href": "/"
{
"_links": {
"bmx_token": {
"href": "/token"
},
"self": {
"href": "/"
}
},
"askAdapter": false,
"assets": {
"color": "#000000",
"description": "Custom radio stations with BMX.",
"icons": {
"largeSvg": "{MEDIA_SERVER}/bmx-icons/orion/monochrome.svg",
"monochromePng": "{MEDIA_SERVER}/bmx-icons/orion/monochrome_v2.png",
"monochromeSvg": "{MEDIA_SERVER}/bmx-icons/orion/monochrome.svg",
"smallSvg": "{MEDIA_SERVER}/bmx-icons/orion/monochrome.svg"
},
"name": "Custom Stations"
},
"authenticationModel": {
"anonymousAccount": {
"autoCreate": true,
"enabled": true
}
},
"baseUrl": "{BMX_SERVER}/core02/svc-bmx-adapter-orion/prod/orion",
"id": {
"name": "LOCAL_INTERNET_RADIO",
"value": 11
},
"streamTypes": [
"liveRadio"
]
},
{
"_links": {
"bmx_availability": {
"href": "/availability"
},
"bmx_logout": {
"href": "/logout"
},
"bmx_navigate": {
"href": "/navigate/"
},
"bmx_token": {
"href": "/token"
},
"self": {
"href": "/"
}
},
"askAdapter": false,
"assets": {
"color": "#004b85",
"description": "Over 200 channels including commercial-free music, plus play-by-play and sports talk, world class news, comedy, exclusive entertainment and more.",
"icons": {
"largeSvg": "{MEDIA_SERVER}/bmx-icons/siriusxm-everest/SiriusXM_Logo_Color.svg",
"monochromePng": "{MEDIA_SERVER}/bmx-icons/siriusxm-everest/monochromePng.png",
"monochromeSvg": "{MEDIA_SERVER}/bmx-icons/siriusxm-everest/SiriusXM_Logo_Mono.svg",
"smallSvg": "{MEDIA_SERVER}/bmx-icons/siriusxm-everest/SiriusXM_Logo_Color.svg"
},
"name": "SiriusXM",
"shortDescription": "Over 200 channels including commercial-free music, plus play-by-play and sports talk, world class news, comedy, exclusive entertainment and more."
},
"authenticationModel": {
"loginPageProvider": "BOSE"
},
"baseUrl": "{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter",
"id": {
"name": "SIRIUSXM_EVEREST",
"value": 38
},
"signupUrl": "https://streaming.siriusxm.com/?/flepz=true&campaign=bose30#_frmAccountLookup",
"streamTypes": [
"liveRadio",
"onDemand"
]
},
{
"_links": {
"bmx_availability": {
"href": "/availability"
},
"bmx_navigate": {
"href": "/navigate"
},
"bmx_token": {
"href": "{BMX_SERVER}/soundtouch-msp-token-proxy/RADIOPLAYER/token"
},
"self": {
"href": "/"
}
},
"askAdapter": false,
"assets": {
"color": "#cc0033",
"description": "Radio for you, from your country. Radioplayer is a unique broadcaster owned service, with higher quality streams, full content (including all live sport), and thousands of catch-up programs and podcasts. Radioplayer is available in UK, Germany, Canada, Austria, Belgium, Denmark, Ireland, Italy, Norway, Spain and Switzerland.",
"icons": {
"largeSvg": "https://donpvpd81xeci.cloudfront.net/icons/small.svg",
"monochromePng": "https://donpvpd81xeci.cloudfront.net/icons/monochrome.png",
"monochromeSvg": "https://donpvpd81xeci.cloudfront.net/icons/monochrome.svg",
"smallSvg": "https://donpvpd81xeci.cloudfront.net/icons/small.svg"
},
"name": "Radioplayer"
},
"authenticationModel": {
"anonymousAccount": {
"autoCreate": false,
"enabled": true
}
},
"baseUrl": "https://boserp.radioapi.io",
"id": {
"name": "RADIOPLAYER",
"value": 35
},
"streamTypes": [
"liveRadio",
"onDemand"
]
},
{
"_links": {
"bmx_navigate": {
"href": "/v1/navigate"
},
"bmx_token": {
"href": "/v1/token"
},
"self": {
"href": "/"
}
},
"askAdapter": false,
"assets": {
"color": "#000000",
"description": "RadioBrowser is an open source internet radio directory. It provides access to thousands of internet radio stations worldwide. RadioBrowser is community driven and relies on user contributions to keep the station database up to date.",
"icons": {
"largeSvg": "{MEDIA_SERVER}/bmx-icons/orion/monochrome.svg",
"monochromePng": "{MEDIA_SERVER}/bmx-icons/orion/monochrome_v2.png",
"monochromeSvg": "{MEDIA_SERVER}/bmx-icons/orion/monochrome.svg",
"smallSvg": "{MEDIA_SERVER}/bmx-icons/orion/monochrome.svg"
},
"name": "RadioBrowser"
},
"authenticationModel": {
"anonymousAccount": {
"autoCreate": true,
"enabled": true
}
},
"baseUrl": "https://all.api.radio-browser.info/soundtouch",
"id": {
"name": "RADIO_BROWSER",
"value": 39
},
"streamTypes": [
"liveRadio",
"onDemand"
]
}
},
"askAdapter": false,
"assets": {
"color": "#000000",
"description": "Custom radio stations with BMX.",
"icons": {
"largeSvg": "{MEDIA_SERVER}/orion-monochrome.svg",
"monochromePng": "{MEDIA_SERVER}/orion-monochrome_v2.png",
"monochromeSvg": "{MEDIA_SERVER}/orion-monochrome.svg",
"smallSvg": "{MEDIA_SERVER}/orion-monochrome.svg"
},
"name": "Custom Stations"
},
"authenticationModel": {
"anonymousAccount": {
"autoCreate": true,
"enabled": true
}
},
"baseUrl": "{BMX_SERVER}/core02/svc-bmx-adapter-orion/prod/orion",
"id": {
"name": "LOCAL_INTERNET_RADIO",
"value": 11
},
"streamTypes": [
"liveRadio"
]
},
{
"_links": {
"bmx_availability": {
"href": "/availability"
},
"bmx_logout": {
"href": "/logout"
},
"bmx_navigate": {
"href": "/navigate/"
},
"bmx_token": {
"href": "/token"
},
"self": {
"href": "/"
}
},
"askAdapter": false,
"assets": {
"color": "#004b85",
"description": "Over 200 channels including commercial-free music, plus play-by-play and sports talk, world class news, comedy, exclusive entertainment and more.",
"icons": {
"largeSvg": "{MEDIA_SERVER}/SiriusXM_Logo_Color.svg",
"monochromePng": "{MEDIA_SERVER}/siriusxm-monochromePng.png",
"monochromeSvg": "{MEDIA_SERVER}/SiriusXM_Logo_Mono.svg",
"smallSvg": "{MEDIA_SERVER}/SiriusXM_Logo_Color.svg"
},
"name": "SiriusXM",
"shortDescription": "Over 200 channels including commercial-free music, plus play-by-play and sports talk, world class news, comedy, exclusive entertainment and more."
},
"authenticationModel": {
"loginPageProvider": "BOSE"
},
"baseUrl": "{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter",
"id": {
"name": "SIRIUSXM_EVEREST",
"value": 38
},
"signupUrl": "https://streaming.siriusxm.com/?/flepz=true&campaign=bose30#_frmAccountLookup",
"streamTypes": [
"liveRadio",
"onDemand"
]
},
{
"_links": {
"bmx_availability": {
"href": "/availability"
},
"bmx_navigate": {
"href": "/navigate"
},
"bmx_token": {
"href": "{BMX_SERVER}/soundtouch-msp-token-proxy/RADIOPLAYER/token"
},
"self": {
"href": "/"
}
},
"askAdapter": false,
"assets": {
"color": "#cc0033",
"description": "Radio for you, from your country. Radioplayer is a unique broadcaster owned service, with higher quality streams, full content (including all live sport), and thousands of catch-up programs and podcasts. Radioplayer is available in UK, Germany, Canada, Austria, Belgium, Denmark, Ireland, Italy, Norway, Spain and Switzerland.",
"icons": {
"largeSvg": "https://donpvpd81xeci.cloudfront.net/icons/small.svg",
"monochromePng": "https://donpvpd81xeci.cloudfront.net/icons/monochrome.png",
"monochromeSvg": "https://donpvpd81xeci.cloudfront.net/icons/monochrome.svg",
"smallSvg": "https://donpvpd81xeci.cloudfront.net/icons/small.svg"
},
"name": "Radioplayer"
},
"authenticationModel": {
"anonymousAccount": {
"autoCreate": false,
"enabled": true
}
},
"baseUrl": "https://boserp.radioapi.io",
"id": {
"name": "RADIOPLAYER",
"value": 35
},
"streamTypes": [
"liveRadio",
"onDemand"
]
},
{
"_links": {
"bmx_navigate": {
"href": "/v1/navigate"
},
"bmx_token": {
"href": "/v1/token"
},
"self": {
"href": "/"
}
},
"askAdapter": false,
"assets": {
"color": "#000000",
"description": "RadioBrowser is an open source internet radio directory. It provides access to thousands of internet radio stations worldwide. RadioBrowser is community driven and relies on user contributions to keep the station database up to date.",
"icons": {
"largeSvg": "{MEDIA_SERVER}/orion-monochrome.svg",
"monochromePng": "{MEDIA_SERVER}/orion-monochrome_v2.png",
"monochromeSvg": "{MEDIA_SERVER}/orion-monochrome.svg",
"smallSvg": "{MEDIA_SERVER}/orion-monochrome.svg"
},
"name": "RadioBrowser"
},
"authenticationModel": {
"anonymousAccount": {
"autoCreate": true,
"enabled": true
}
},
"baseUrl": "https://all.api.radio-browser.info/soundtouch",
"id": {
"name": "RADIO_BROWSER",
"value": 39
},
"streamTypes": [
"liveRadio",
"onDemand"
]
}
]
]
}
@@ -0,0 +1,132 @@
{
"_links": {
"bmx_services_availability": {
"href": "../servicesAvailability"
}
},
"askAgainAfter": 1243079,
"bmx_services": [
{
"_links": {
"bmx_navigate": {
"href": "/v1/navigate"
},
"bmx_token": {
"href": "/v1/token"
},
"self": {
"href": "/"
}
},
"askAdapter": false,
"assets": {
"color": "#000000",
"description": "With TuneIn on SoundTouch, listen to more than 100,000 stations and the hottest podcasts, plus live games, concerts and shows from around the world. However, you cannot access your Favorites and Premium content on your existing TuneIn account at this time.",
"icons": {
"defaultAlbumArt": "https://media.bose.io/bmx-icons/tunein/default-album-art.png",
"largeSvg": "https://media.bose.io/bmx-icons/tunein/smallSvg.svg",
"monochromePng": "https://media.bose.io/bmx-icons/tunein/monochromePng.png",
"monochromeSvg": "https://media.bose.io/bmx-icons/tunein/monochromeSvg.svg",
"smallSvg": "https://media.bose.io/bmx-icons/tunein/smallSvg.svg"
},
"name": "TuneIn"
},
"authenticationModel": {
"anonymousAccount": {
"autoCreate": true,
"enabled": true
}
},
"baseUrl": "https://content.api.bose.io/bmx/tunein",
"id": {
"name": "TUNEIN",
"value": 25
},
"streamTypes": [
"liveRadio",
"onDemand"
]
},
{
"_links": {
"bmx_token": {
"href": "/token"
},
"self": {
"href": "/"
}
},
"askAdapter": false,
"assets": {
"color": "#000000",
"description": "Custom radio stations with BMX.",
"icons": {
"largeSvg": "https://media.bose.io/bmx-icons/orion/monochrome.svg",
"monochromePng": "https://media.bose.io/bmx-icons/orion/monochrome_v2.png",
"monochromeSvg": "https://media.bose.io/bmx-icons/orion/monochrome.svg",
"smallSvg": "https://media.bose.io/bmx-icons/orion/monochrome.svg"
},
"name": "Custom Stations"
},
"authenticationModel": {
"anonymousAccount": {
"autoCreate": true,
"enabled": true
}
},
"baseUrl": "https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion",
"id": {
"name": "LOCAL_INTERNET_RADIO",
"value": 11
},
"streamTypes": [
"liveRadio"
]
},
{
"_links": {
"bmx_availability": {
"href": "/availability"
},
"bmx_logout": {
"href": "/logout"
},
"bmx_navigate": {
"href": "/navigate/"
},
"bmx_token": {
"href": "/token"
},
"self": {
"href": "/"
}
},
"askAdapter": false,
"assets": {
"color": "#004b85",
"description": "Over 200 channels including commercial-free music, plus play-by-play and sports talk, world class news, comedy, exclusive entertainment and more.",
"icons": {
"largeSvg": "https://media.bose.io/bmx-icons/siriusxm-everest/SiriusXM_Logo_Color.svg",
"monochromePng": "https://media.bose.io/bmx-icons/siriusxm-everest/monochromePng.png",
"monochromeSvg": "https://media.bose.io/bmx-icons/siriusxm-everest/SiriusXM_Logo_Mono.svg",
"smallSvg": "https://media.bose.io/bmx-icons/siriusxm-everest/SiriusXM_Logo_Color.svg"
},
"name": "SiriusXM",
"shortDescription": "Over 200 channels including commercial-free music, plus play-by-play and sports talk, world class news, comedy, exclusive entertainment and more."
},
"authenticationModel": {
"loginPageProvider": "BOSE"
},
"baseUrl": "https://content.api.bose.io/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter",
"id": {
"name": "SIRIUSXM_EVEREST",
"value": 38
},
"signupUrl": "https://streaming.siriusxm.com/?/flepz=true&campaign=bose30#_frmAccountLookup",
"streamTypes": [
"liveRadio",
"onDemand"
]
}
]
}

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 957 B

After

Width:  |  Height:  |  Size: 957 B

Before

Width:  |  Height:  |  Size: 631 B

After

Width:  |  Height:  |  Size: 631 B

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -165,3 +165,10 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px;
font-size: 0.8em;
font-weight: bold;
}
.logo {
height: 40px;
vertical-align: middle;
margin-right: 10px;
margin-top: -5px;
}

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 418 B

After

Width:  |  Height:  |  Size: 418 B

Before

Width:  |  Height:  |  Size: 681 B

After

Width:  |  Height:  |  Size: 681 B

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 859 B

Before

Width:  |  Height:  |  Size: 246 B

After

Width:  |  Height:  |  Size: 246 B

Before

Width:  |  Height:  |  Size: 381 B

After

Width:  |  Height:  |  Size: 381 B

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More