Compare commits

..
11 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
67 changed files with 4285 additions and 798 deletions
+7 -1
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
+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
+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"
+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 {
+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)
}
+24
View File
@@ -2038,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
+47 -24
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)",
@@ -241,7 +246,7 @@ func main() {
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,
@@ -667,6 +677,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
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)
@@ -724,8 +736,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
})
r.Route("/oauth", func(r chi.Router) {
r.Post("/device/{deviceID}/music/musicprovider/15/token/cs3", server.HandleBoseSpotifyToken)
r.Post("/device/{deviceID}/music/musicprovider/15/token", server.HandleBoseSpotifyLegacyToken)
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)
})
@@ -743,14 +755,25 @@ 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)
})
})
+25 -23
View File
@@ -3,10 +3,19 @@
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` to the `buttonNumber` XML attribute in the `/full` response.
* **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.
@@ -37,19 +46,21 @@ This document summarizes the improvements made to the **Marge service** to impro
#### 🛠️ Open Issues and Next Steps
Based on the latest `parity_mismatches`, here are the recommended areas for further work:
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=3432432423`, while upstream uses a different session-based ID.
* **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. Presets and Recents Parity (Medium)
Further align the standalone `GET /presets` and `GET /recents` endpoints with the refined structural improvements introduced for the `/full` account response:
* **Source Nesting**: Ensure the standalone responses also use the specialized nested `<source>` structure instead of mixed attributes when appropriate.
* **Field Completeness**: Verify all metadata fields (e.g., `<contentItemType>`, `<lastplayedat>`) are consistently populated across all access paths.
* **Action**: Evaluate if the specialized `FullResponsePreset` and `FullResponseRecent` models should be shared or mirrored in the standalone handlers.
#### 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.
@@ -68,19 +79,10 @@ 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 (In Progress)
Based on `_/diffs/diff7/`, several structural and value gaps remain in the `/full` account response:
#### 7. Account Full Response (/full) Structural & Value Parity (Completed)
Structural and value gaps in the `/full` account response have been addressed:
**Remaining Findings:**
* **Nested Source Inconsistency in Recents**: The `<source>` element within `<recent>` entries still frequently points to a generic fallback (ID `9330201`) instead of the specific source (e.g., Spotify ID `10863533`).
* Missing/empty `<username>` at the `<preset>` level.
* **Values**:
* **Empty Device `<name>`**: Locally, the device `<name>` is empty in the response even when available in the datastore or upstream.
* **Empty `<contentItemType>`**: Local responses have empty `<contentItemType>` in presets and recents, whereas upstream has `tracklisturl` or `stationurl`.
* `preferredLanguage` mismatch (`en` vs `de`).
**Next Implementation Steps (Proposals):**
1. **Fix Device `<name>` Population**: Investigate why `CreateAccountDevice` or `AccountFullToXML` is not correctly returning the device name even if it's synchronized.
2. **Refine Source Association in Recents**: Improve the matching logic in `mapRecentsToFullResponse` to correctly link recents to their specific `ConfiguredSource` (e.g., by matching `sourceid` attribute).
3. **Populate `contentItemType`**: Update the internal models and `SyncFromAccountFull` to correctly extract, persist, and echo back `contentItemType` (e.g., `tracklisturl`).
4. **Handle Account Metadata**: Synchronize `preferredLanguage` from the upstream `/full` response to the local account state.
**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.
+5
View File
@@ -57,6 +57,10 @@
* [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 +85,4 @@
* [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)
+4
View File
@@ -8,6 +8,8 @@ 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.49.0
)
@@ -15,9 +17,11 @@ require (
require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // 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
)
+8
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=
@@ -26,6 +30,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
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=
@@ -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=
+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
View File
@@ -2,6 +2,7 @@ package client
import (
"context"
"encoding/xml"
"fmt"
"log"
"net/url"
@@ -519,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()
+314 -83
View File
@@ -137,8 +137,8 @@ type ServiceContentItem struct {
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" xml:"location"`
SourceAccount string `json:"source_account,omitempty" xml:"sourceAccount,attr,omitempty"`
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"`
}
@@ -146,6 +146,7 @@ type ServiceContentItem struct {
// ServicePreset represents a user-defined preset for quick access to media content.
type ServicePreset struct {
ServiceContentItem
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"`
@@ -158,35 +159,212 @@ type ServicePreset struct {
type ServiceRecent struct {
XMLName xml.Name `json:"-" xml:"recent"`
ServiceContentItem
DeviceID string `json:"device_id" xml:"deviceid,attr"`
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 {
XMLName xml.Name `json:"-" xml:"source"`
DisplayName string `json:"display_name" xml:"name"`
ID string `json:"id" xml:"id,attr"`
Secret string `json:"secret" xml:"credential"`
SecretType string `json:"secret_type" xml:"credential_type,attr"`
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:"source_key"`
Type string `xml:"type,attr"`
} `json:"source_key" xml:"sourceKey"`
Type string `xml:"type,attr,omitempty"`
// Parity fields
CreatedOn string `json:"created_on,omitempty" xml:"createdOn"`
UpdatedOn string `json:"updated_on,omitempty" xml:"updatedOn"`
SourceProviderID string `json:"sourceproviderid,omitempty" xml:"sourceproviderid"`
Username string `json:"username,omitempty" xml:"username"`
SourceName string `json:"source_name,omitempty" xml:"sourcename"`
SourceSettings string `json:"-" xml:"sourceSettings"`
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.
@@ -196,20 +374,38 @@ type ConfiguredSource struct {
// 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 ConfiguredSource
a := struct {
Alias
Username string `xml:"username"`
SourceName string `xml:"sourcename"`
SourceSettings string `xml:"sourceSettings"`
}{
Alias: Alias(s),
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.Username = s.Username
a.SourceName = s.SourceName
// We want <sourceSettings/>
a.SourceSettings = ""
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)
}
@@ -231,11 +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,omitempty"`
SoftwareVersion string `xml:"firmware-version"`
SerialNumber string `xml:"serialnumber"`
Label string `xml:"componentlabel,omitempty"`
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.
@@ -361,45 +565,48 @@ type EmailAddressResponse struct {
// 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 `xml:"id,attr"`
Type string `xml:"type,attr"`
CreatedOn string `xml:"createdOn"`
Credential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
} `xml:"credential"`
Name string `xml:"name"`
SourceProviderID string `xml:"sourceproviderid"`
SourceName string `xml:"sourcename"`
SourceSettings string `xml:"sourceSettings"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
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 `xml:"buttonNumber,attr"`
ContainerArt string `xml:"containerArt"`
ContentItemType string `xml:"contentItemType"`
CreatedOn string `xml:"createdOn"`
Location string `xml:"location"`
Name string `xml:"name"`
Source FullResponseSource `xml:"source"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
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 `xml:"id,attr"`
ContentItemType string `xml:"contentItemType"`
CreatedOn string `xml:"createdOn"`
LastPlayedAt string `xml:"lastplayedat"`
Location string `xml:"location"`
Name string `xml:"name"`
Source FullResponseSource `xml:"source"`
SourceID string `xml:"sourceid"`
UpdatedOn string `xml:"updatedOn"`
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.
@@ -416,31 +623,55 @@ type AccountFullResponse struct {
// AccountDevice represents a device in the account response.
type AccountDevice struct {
DeviceID string `xml:"deviceid,attr"`
AttachedProduct *AttachedProduct `xml:"attachedProduct"`
CreatedOn string `xml:"createdOn"`
FirmwareVersion string `xml:"firmwareVersion"`
IPAddress string `xml:"ipaddress"`
Name string `xml:"name"`
Presets []FullResponsePreset `xml:"presets>preset"`
Recents []FullResponseRecent `xml:"recents>recent"`
SerialNumber string `xml:"serialNumber"`
UpdatedOn string `xml:"updatedOn"`
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 `xml:"product_code,attr"`
Components []ServiceComponent `xml:"components>component"`
ProductLabel string `xml:"productlabel"`
SerialNumber string `xml:"serialnumber"`
UpdatedOn string `xml:"updatedOn"`
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 `xml:"boseId"`
KeyName string `xml:"keyName"`
Value string `xml:"value"`
ProviderID string `xml:"providerId"`
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
+102 -39
View File
@@ -1,55 +1,118 @@
// 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", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
{ID: 2, Name: "INTERNET_RADIO", CreatedOn: "2012-09-19T12:43:00.000+00:00", UpdatedOn: "2012-09-19T12:43:00.000+00:00"},
{ID: 3, Name: "OFF", CreatedOn: "2012-10-22T16:03:00.000+00:00", UpdatedOn: "2012-10-22T16:03:00.000+00:00"},
{ID: 4, Name: "LOCAL", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 5, Name: "AIRPLAY", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 6, Name: "CURRATED_RADIO", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 7, Name: "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", CreatedOn: "2012-10-22T16:04:00.000+00:00", UpdatedOn: "2012-10-22T16:04:00.000+00:00"},
{ID: 9, Name: "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", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
{ID: 11, Name: "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", CreatedOn: "2013-01-10T09:45:00.000+00:00", UpdatedOn: "2013-01-10T09:45:00.000+00:00"},
{ID: 13, Name: "HELLO", CreatedOn: "2014-03-17T15:30:07.000+00:00", UpdatedOn: "2014-03-17T15:30:07.000+00:00"},
{ID: 14, Name: "DEEZER", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: 15, Name: "SPOTIFY", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: 16, Name: "IHEART", CreatedOn: "2014-03-17T15:30:27.000+00:00", UpdatedOn: "2014-03-17T15:30:27.000+00:00"},
{ID: 17, Name: "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", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 19, Name: "QQMUSIC", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 20, Name: "AMAZON", CreatedOn: "2014-12-04T19:49:55.000+00:00", UpdatedOn: "2014-12-04T19:49:55.000+00:00"},
{ID: 21, Name: "LOCAL_MUSIC", CreatedOn: "2015-07-13T12:00:00.000+00:00", UpdatedOn: "2015-07-13T12:00:00.000+00:00"},
{ID: 22, Name: "WBMX", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 23, Name: "SOUNDCLOUD", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 24, Name: "TIDAL", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 25, Name: "TUNEIN", CreatedOn: "2016-04-08T17:27:21.000+00:00", UpdatedOn: "2016-04-08T17:27:21.000+00:00"},
{ID: 26, Name: "QPLAY", CreatedOn: "2016-06-17T18:00:54.000+00:00", UpdatedOn: "2016-06-17T18:00:54.000+00:00"},
{ID: 27, Name: "JUKE", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 28, Name: "BBC", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 29, Name: "DARFM", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 30, Name: "7DIGITAL", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 31, Name: "SAAVN", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 32, Name: "RDIO", CreatedOn: "2016-08-01T13:53:40.000+00:00", UpdatedOn: "2016-08-01T13:53:40.000+00:00"},
{ID: 33, Name: "PHONE_MUSIC", CreatedOn: "2016-10-26T14:42:49.000+00:00", UpdatedOn: "2016-10-26T14:42:49.000+00:00"},
{ID: 34, Name: "ALEXA", CreatedOn: "2017-12-04T19:18:47.000+00:00", UpdatedOn: "2017-12-04T19:18:47.000+00:00"},
{ID: 35, Name: "RADIOPLAYER", CreatedOn: "2019-05-28T18:21:20.000+00:00", UpdatedOn: "2019-05-28T18:21:20.000+00:00"},
{ID: 36, Name: "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", CreatedOn: "2019-06-13T17:30:47.000+00:00", UpdatedOn: "2019-06-13T17:30:47.000+00:00"},
{ID: 38, Name: "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", CreatedOn: "2026-03-14T22:47:00.000+00:00", UpdatedOn: "2026-03-14T22:47:00.000+00:00"},
{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.
+187 -68
View File
@@ -105,6 +105,45 @@ func (ds *DataStore) safeJoin(elem ...string) string {
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 ds.safeJoin("accounts", account)
@@ -199,6 +238,12 @@ func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.Servic
}
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
@@ -459,15 +504,15 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
CreatedOn string `xml:"createdOn,attr"`
UpdatedOn string `xml:"updatedOn,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"`
ContentItemType string `xml:"contentItemType"`
ContainerArt string `xml:"containerArt"`
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"`
Source *models.ConfiguredSource `xml:"source"`
} `xml:"preset"`
}
@@ -480,14 +525,10 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
for i := range presetsWrap.Presets {
p := &presetsWrap.Presets[i]
cit := p.ContentItem.ContentItemType
if cit == "" {
cit = p.ContentItem.Type
}
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,
@@ -496,9 +537,12 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
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,
})
}
@@ -511,21 +555,24 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
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"`
CreatedOn string `xml:"createdOn,attr"`
UpdatedOn string `xml:"updatedOn,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"`
ContentItemType string `xml:"contentItemType"`
ContainerArt string `xml:"containerArt"`
Source string `xml:"source,attr,omitempty"`
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"`
Source *models.ConfiguredSource `xml:"source,omitempty"`
}
type PresetsXML struct {
@@ -540,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
@@ -549,8 +600,8 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
pxml.ContentItem.SourceAccount = p.SourceAccount
pxml.ContentItem.IsPresetable = "true"
pxml.ContentItem.ItemName = p.Name
pxml.ContentItem.ContentItemType = p.ContentItemType
pxml.ContentItem.ContainerArt = p.ContainerArt
pxml.Source = p.SourceConfig
px.Presets = append(px.Presets, pxml)
}
@@ -592,6 +643,7 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
for i := range recents {
r := &recents[i]
if id, err := strconv.Atoi(r.ID); err == nil {
if id > maxID {
maxID = id
@@ -619,7 +671,12 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
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"`
@@ -730,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,
@@ -744,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, "", " ")
@@ -765,6 +838,52 @@ 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()
@@ -800,27 +919,26 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
return nil, fmt.Errorf("malformed sources XML at %s: %w", path, err)
}
// Helper struct for unmarshaling with displayName
var sourcesWithDisplayName struct {
Sources []struct {
DisplayName string `xml:"displayName,attr"`
} `xml:"source"`
}
_ = xml.Unmarshal(data, &sourcesWithDisplayName)
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)
}
if s.DisplayName == "" && i < len(sourcesWithDisplayName.Sources) {
s.DisplayName = sourcesWithDisplayName.Sources[i].DisplayName
}
// Sync legacy fields
s.SourceKeyType = s.SourceKey.Type
s.SourceKeyAccount = s.SourceKey.Account
}
return sourcesWrap.Sources, nil
@@ -1021,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) {
+2 -2
View File
@@ -15,8 +15,8 @@ func TestSaveDeviceInfo_MergesName(t *testing.T) {
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
account := "3230304"
device := "A81B6A536A98"
account := "1234567"
device := "001122334455"
// 1. Initial save with name
info1 := &models.ServiceDeviceInfo{
@@ -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")
}
}
@@ -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)
}
}
+108
View File
@@ -1,9 +1,11 @@
package handlers
import (
"crypto/rand"
"encoding/xml"
"io"
"log"
"math/big"
"net"
"net/http"
"strconv"
@@ -15,6 +17,112 @@ import (
"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)
+146 -5
View File
@@ -2,6 +2,7 @@ package handlers
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"net/http"
@@ -11,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)
@@ -722,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)
@@ -745,13 +886,13 @@ func TestMargePowerOn(t *testing.T) {
ts2 := httptest.NewServer(r)
defer ts2.Close()
deviceID := "A81B6A536A98"
deviceID := "001122334455"
serialNumber := "I6332527703739342000020"
firmware := "27.0.6.46330"
productCode := "SoundTouch 10 sm2"
productSerial := "069231P63364828AE"
ipAddress := "192.168.1.100"
macAddress := "A81B6A536A98"
macAddress := "001122334455"
payload := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" ?>
<device-data>
@@ -943,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)
+24 -7
View File
@@ -5,9 +5,33 @@ import (
"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) {
@@ -61,10 +85,3 @@ func (s *Server) HandleBoseSpotifyToken(w http.ResponseWriter, r *http.Request)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
// HandleBoseSpotifyLegacyToken handles the Bose-specific Spotify token refresh request (legacy or variant).
// POST /oauth/device/{deviceID}/music/musicprovider/15/token
func (s *Server) HandleBoseSpotifyLegacyToken(w http.ResponseWriter, r *http.Request) {
// Some firmware might use a slightly different path.
s.HandleBoseSpotifyToken(w, r)
}
+5 -5
View File
@@ -49,7 +49,7 @@ func TestHandleBoseSpotifyToken_LocalResponse(t *testing.T) {
// chi.URLParam works when using chi router
r := chi.NewRouter()
r.Post("/oauth/device/{deviceID}/music/musicprovider/15/token/cs3", server.HandleBoseSpotifyToken)
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()
@@ -81,11 +81,11 @@ func TestHandleBoseSpotifyToken_FallbackToProxy(t *testing.T) {
// 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, "")
server.SetMirrorSettings(true, nil, nil, "")
// chi.URLParam works when using chi router
r := chi.NewRouter()
r.Post("/oauth/device/{deviceID}/music/musicprovider/15/token/cs3", server.HandleBoseSpotifyToken)
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.
@@ -113,13 +113,13 @@ func TestHandleBoseSpotifyToken_FallbackToProxy(t *testing.T) {
}
}
func TestHandleBoseSpotifyLegacyToken(t *testing.T) {
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/15/token", server.HandleBoseSpotifyLegacyToken)
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)
+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
@@ -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">
@@ -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>
@@ -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)
@@ -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)
@@ -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"},
}
+2
View File
@@ -57,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) {
-82
View File
@@ -1,82 +0,0 @@
package handlers
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestMirrorMiddleware_HostHeader(t *testing.T) {
tempDir, err := os.MkdirTemp("", "mirror-host-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
// 1. Setup local handler
r := http.NewServeMux()
r.HandleFunc("/bmx/test", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Source", "local")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("local response"))
})
// 2. Setup "upstream" mock server
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Source", "upstream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("upstream response"))
}))
defer upstreamServer.Close()
// 3. Setup our server with MirrorMiddleware
// Use soundtouch.fritz.box as the server URL
server := NewServer(ds, nil, "https://soundtouch.fritz.box", false, false, false)
server.SetMirrorSettings(true, []string{"/bmx/*"}, "upstream")
middleware := server.MirrorMiddleware(r)
t.Run("ProxiesToBoseWhenHostHeaderIsLocal", func(t *testing.T) {
// Simulate a request from a speaker to the local service
req := httptest.NewRequest("GET", "/bmx/tunein/v1/test", nil)
req.Host = "soundtouch.fritz.box"
w := httptest.NewRecorder()
// Since performMirror will now detect soundtouch.fritz.box as local
// and map it to content.api.bose.io, we can check if it tries to reach it.
// However, in this test environment, we still don't have content.api.bose.io.
// But we can check if the internal state of performMirror would have used it.
// To make it testable, we'd need to mock the proxy or the host mapping.
// For now, let's just ensure it DOESN'T loop to itself and attempts
// to go to the mapped host.
middleware.ServeHTTP(w, req)
// It should attempt to mirror, and since status 403 (from some real bose endpoint or cloudflare?)
// is < 500, it actually uses it if preferredSource is upstream.
// In this environment, it actually returned 403.
if w.Code != 403 && w.Code != http.StatusOK {
t.Errorf("Expected status 403 or 200, got %d", w.Code)
}
})
t.Run("ProxiesToUpstreamWhenHostHeaderIsCorrect", func(t *testing.T) {
req := httptest.NewRequest("GET", "/bmx/test", nil)
req.Host = strings.TrimPrefix(upstreamServer.URL, "http://")
w := httptest.NewRecorder()
middleware.ServeHTTP(w, req)
if w.Header().Get("X-Source") != "upstream" {
t.Errorf("Expected X-Source: upstream, got %s", w.Header().Get("X-Source"))
}
})
}
@@ -20,7 +20,7 @@ func TestMirrorMiddleware_InfiniteLoop(t *testing.T) {
_ = ds.Initialize()
server := NewServer(ds, nil, "http://localhost:8000", false, false, false)
server.SetMirrorSettings(true, []string{"/loop"}, "upstream")
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.
+53 -86
View File
@@ -26,10 +26,10 @@ import (
// 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 || isMirrorRequest || 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
}
@@ -64,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 {
@@ -81,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)
@@ -215,87 +225,36 @@ func (s *Server) performMirror(r *http.Request) *mirrorResponseRecorder {
}
// Preserve request body for recording before it gets consumed by the proxy
requestForRecording := s.prepareRequestForRecording(r, snapshot)
target := s.resolveMirrorTarget(r)
if target == nil {
return nil
}
// Capture response for parity check and recording
recorder := &mirrorResponseRecorder{
headers: make(http.Header),
body: &bytes.Buffer{},
}
proxy := s.createMirrorProxy(target, requestForRecording)
// We use a dummy ResponseWriter to capture the results
proxy.ServeHTTP(recorder, r)
log.Printf("[MIRROR] Mirror completed for %s with status %d", r.URL.Path, recorder.status)
return recorder
}
func (s *Server) prepareRequestForRecording(r *http.Request, snapshot *RequestSnapshot) *http.Request {
if s.recorder == nil || !s.recordEnabled {
return nil
}
requestForRecording := r.Clone(r.Context())
if snapshot != nil {
// Use snapshot for both proxy and recording
r.Body = io.NopCloser(bytes.NewReader(snapshot.Body))
requestForRecording.Body = io.NopCloser(bytes.NewReader(snapshot.Body))
requestForRecording.ContentLength = int64(len(snapshot.Body))
} else if r.Body != nil {
// Compatibility fallback
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("[MIRROR_ERR] Failed to read request body for recording: %v", err)
return nil
var requestForRecording *http.Request
if s.recorder != nil && s.recordEnabled {
requestForRecording = r.Clone(r.Context())
if snapshot != nil {
// Use snapshot for both proxy and recording
r.Body = io.NopCloser(bytes.NewReader(snapshot.Body))
requestForRecording.Body = io.NopCloser(bytes.NewReader(snapshot.Body))
} else if r.Body != nil {
// Compatibility fallback
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("[MIRROR_ERR] Failed to read request body for recording: %v", err)
} else {
// Restore body for proxy
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// Set body for recording
requestForRecording.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
// Ensure Content-Length is set for the recording clone
if requestForRecording.Body != nil {
if snapshot != nil {
requestForRecording.ContentLength = int64(len(snapshot.Body))
}
}
// Restore body for proxy
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// Set body for recording
requestForRecording.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
return requestForRecording
}
func (s *Server) resolveMirrorTarget(r *http.Request) *url.URL {
host := r.Host
s.mu.RLock()
localServerURL := s.serverURL
httpsServerURL := s.httpsServerURL
s.mu.RUnlock()
isLocalHost := host == "" || host == "localhost"
if localServerURL != "" {
u, err := url.Parse(localServerURL)
if err == nil && host == u.Host {
isLocalHost = true
}
}
if httpsServerURL != "" {
u, err := url.Parse(httpsServerURL)
if err == nil && host == u.Host {
isLocalHost = true
}
}
if isLocalHost {
if strings.HasPrefix(r.URL.Path, "/bmx/tunein") {
host = "content.api.bose.io"
} else {
host = "streaming.bose.com"
}
} else if host == "" {
if host == "" || host == "localhost" {
host = "streaming.bose.com"
}
@@ -312,10 +271,7 @@ func (s *Server) resolveMirrorTarget(r *http.Request) *url.URL {
return nil
}
return target
}
func (s *Server) createMirrorProxy(target *url.URL, requestForRecording *http.Request) *httputil.ReverseProxy {
// Create a proxy that doesn't write to the original ResponseWriter
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(target)
@@ -327,6 +283,12 @@ func (s *Server) createMirrorProxy(target *url.URL, requestForRecording *http.Re
},
}
// Capture response for parity check and recording
recorder := &mirrorResponseRecorder{
headers: make(http.Header),
body: &bytes.Buffer{},
}
proxy.ModifyResponse = func(res *http.Response) error {
res.Header.Set("X-Proxy-Origin", "upstream-mirror")
@@ -338,7 +300,12 @@ func (s *Server) createMirrorProxy(target *url.URL, requestForRecording *http.Re
return nil
}
return proxy
// We use a dummy ResponseWriter to capture the results
proxy.ServeHTTP(recorder, r)
log.Printf("[MIRROR] Mirror completed for %s with status %d", r.URL.Path, recorder.status)
return recorder
}
// checkParity compares local response with upstream response.
@@ -40,7 +40,7 @@ func TestMirrorMiddleware_PreferredSource(t *testing.T) {
// 3. Setup our server with MirrorMiddleware
server := NewServer(ds, nil, "http://localhost:8000", false, false, false)
server.SetMirrorSettings(true, []string{"/test/local"}, "local")
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)
+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"}]}}`
@@ -19,8 +19,8 @@ func TestParityMismatchReproduction_New(t *testing.T) {
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "3230304"
deviceID := "A81B6A536A98"
account := "1234567"
deviceID := "001122334455"
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
@@ -35,7 +35,7 @@ func TestParityMismatchReproduction_New(t *testing.T) {
<name>1LIVE Chillout</name>
<source id="14774275" type="Audio">
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
<credential type="token">eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=</credential>
<credential type="token">dummy-token-base64</credential>
<name></name>
<sourceproviderid>25</sourceproviderid>
<sourcename></sourcename>
@@ -77,23 +77,18 @@ func TestParityMismatchReproduction_New(t *testing.T) {
}
// 3. SourceProviderID learned (25)
if !strings.Contains(bodyStr, "<sourceproviderid>25</sourceproviderid>") {
t.Errorf("SourceProviderID was not learned from POST, expected 25. Body: %s", bodyStr)
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, "eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=") {
t.Errorf("Credential was not learned from POST. Body: %s", bodyStr)
}
// 5. SourceSettings self-closing
if !strings.Contains(bodyStr, "<sourceSettings/>") {
t.Errorf("SourceSettings should be self-closing <sourceSettings/>. Body: %s", bodyStr)
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</createdOn>") {
t.Errorf("Source CreatedOn was not learned from POST. Body: %s", bodyStr)
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)
}
})
@@ -107,11 +102,8 @@ func TestParityMismatchReproduction_New(t *testing.T) {
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, "<sourceproviderid>25</sourceproviderid>") {
t.Errorf("GET /recents missing learned sourceproviderid 25. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, "<sourceSettings/>") {
t.Errorf("GET /recents missing self-closing sourceSettings. Body: %s", bodyStr)
if !strings.Contains(bodyStr, `sourceproviderid="25"`) {
t.Errorf("GET /recents missing learned sourceproviderid 25 in attribute. Body: %s", bodyStr)
}
})
}
@@ -20,8 +20,8 @@ func TestParityMismatchReproduction_V2(t *testing.T) {
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "3230304"
deviceID := "A81B6A536A98"
account := "1234567"
deviceID := "001122334455"
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
@@ -36,7 +36,7 @@ func TestParityMismatchReproduction_V2(t *testing.T) {
<name>1LIVE Chillout</name>
<source id="14774275" type="Audio">
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
<credential type="token">eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=</credential>
<credential type="token">dummy-token-base64</credential>
<name></name>
<sourceproviderid>25</sourceproviderid>
<sourcename></sourcename>
@@ -68,20 +68,12 @@ func TestParityMismatchReproduction_V2(t *testing.T) {
t.Errorf("Date format mismatch. Expected .000+00:00. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, "<sourceproviderid>25</sourceproviderid>") {
t.Errorf("sourceproviderid mismatch. Expected 25. Body: %s", bodyStr)
if !strings.Contains(bodyStr, `sourceproviderid="25"`) {
t.Errorf("sourceproviderid mismatch. Expected 25 in attribute. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, "eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=") {
t.Errorf("Credential value mismatch. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, "<sourceSettings/>") {
t.Errorf("sourceSettings should be self-closing <sourceSettings/>. Body: %s", bodyStr)
}
if !strings.Contains(bodyStr, "<sourcename></sourcename>") {
t.Errorf("sourcename should be empty. 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>") {
@@ -33,7 +33,7 @@ func TestParityMismatchReproduction_V3(t *testing.T) {
<name>1LIVE Chillout</name>
<source id="14774275" type="Audio">
<createdOn>2017-07-20T16:43:48.000+00:00</createdOn>
<credential type="token">eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=</credential>
<credential type="token">dummy-token-base64</credential>
<sourceproviderid>25</sourceproviderid>
<sourcename></sourcename>
<sourceSettings/>
@@ -42,8 +42,8 @@ func TestParityMismatchReproduction_V3(t *testing.T) {
<sourceid>14774275</sourceid>
</recent>`
account := "3230304"
device := "A81B6A536A98"
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) {
@@ -59,6 +59,7 @@ func TestParityMismatchReproduction_V3(t *testing.T) {
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\"")
@@ -77,26 +78,17 @@ func TestParityMismatchReproduction_V3(t *testing.T) {
// 4. Source Learning
// Check for provider ID 25
if !strings.Contains(bodyStr, `<sourceproviderid>25</sourceproviderid>`) {
t.Error("Source provider ID mismatch: expected 25 for TuneIn")
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, `eyJzZXJpYWwiOiAiY2NiZTkzNDMtYjY0MS00MjMxLWFhYTAtOTI3NTBmNjhjMjY3In0=`) {
t.Error("Credential value was not preserved")
}
// Check for empty sourcename
if !strings.Contains(bodyStr, `<sourcename></sourcename>`) {
t.Error("sourcename should be empty for TuneIn")
}
// 5. Self-closing SourceSettings
if !strings.Contains(bodyStr, `<sourceSettings/>`) {
t.Error("sourceSettings should be self-closing")
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 <contentItemType>") {
t.Error("Incorrect indentation: expected 2 spaces")
if !strings.Contains(bodyStr, "\n <contentItem source=\"TUNEIN\"") {
t.Errorf("Incorrect indentation for contentItem: expected 2 spaces. Body: %s", bodyStr)
}
})
@@ -113,12 +105,9 @@ func TestParityMismatchReproduction_V3(t *testing.T) {
t.Logf("GET /recents Local Response:\n%s\n", bodyStr)
if !strings.Contains(bodyStr, `<sourceproviderid>25</sourceproviderid>`) {
if !strings.Contains(bodyStr, `sourceproviderid="25"`) {
t.Error("Source provider ID missing in GET /recents")
}
if !strings.Contains(bodyStr, `<sourceSettings/>`) {
t.Error("sourceSettings should be self-closing in GET /recents")
}
})
}
+9 -14
View File
@@ -20,8 +20,8 @@ func TestMargeParityRegressions(t *testing.T) {
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "3230304"
deviceID := "A81B6A536A98"
account := "1234567"
deviceID := "001122334455"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
os.MkdirAll(deviceDir, 0755)
@@ -44,7 +44,7 @@ func TestMargeParityRegressions(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("POST recent with Other source - sourcename should be empty", func(t *testing.T) {
t.Run("POST recent with Other source - displayName should be 'Other' in attribute", func(t *testing.T) {
payload := `
<recent>
<contentItemType>stationurl</contentItemType>
@@ -68,23 +68,18 @@ func TestMargeParityRegressions(t *testing.T) {
t.Errorf("Response missing standalone=\"yes\"")
}
// Check for empty sourcename when it's "Other"
if !strings.Contains(bodyStr, "<sourcename></sourcename>") && !strings.Contains(bodyStr, "<sourcename/>") {
t.Errorf("Expected empty sourcename for 'Other' source, but got something else or missing. Body: %s", bodyStr)
// 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)
}
// Check for sourceSettings presence
if !strings.Contains(bodyStr, "<sourceSettings>") && !strings.Contains(bodyStr, "<sourceSettings/>") {
t.Errorf("Response missing sourceSettings element. Body: %s", bodyStr)
}
})
t.Run("POST recent with named source - sourcename should be preserved", func(t *testing.T) {
t.Run("POST recent with named source - displayName should be preserved in attribute", func(t *testing.T) {
payload := `
<recent>
<contentItemType>track</contentItemType>
@@ -103,8 +98,8 @@ func TestMargeParityRegressions(t *testing.T) {
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, "<sourcename>My Spotify</sourcename>") {
t.Errorf("Expected sourcename 'My Spotify', body: %s", bodyStr)
if !strings.Contains(bodyStr, `displayName="My Spotify"`) {
t.Errorf("Expected displayName=\"My Spotify\", body: %s", bodyStr)
}
})
}
+2 -2
View File
@@ -23,8 +23,8 @@ func TestMargeRecentConsistencyAndIDParity(t *testing.T) {
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
account := "3230304"
deviceID := "A81B6A536A98"
account := "1234567"
deviceID := "001122334455"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
os.MkdirAll(deviceDir, 0755)
+3 -1
View File
@@ -40,6 +40,7 @@ type Server struct {
dnsBindAddr string
mirrorEnabled bool
mirrorEndpoints []string
skipMirrorEndpoints []string
preferredSource string
internalPaths []string
shortcuts map[string]int
@@ -319,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
}
@@ -27,7 +27,7 @@ func TestSnapshotIntegrity_SelfAndMirror(t *testing.T) {
recorder := proxy.NewRecorder(tempDir)
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) {
+35 -1
View File
@@ -36,6 +36,9 @@
<button class="tab-btn" onclick="openTab(event, 'tab-parity')">
6. Parity & Mirroring
</button>
<button class="tab-btn" onclick="openTab(event, 'tab-account')">
7. Local Account
</button>
</div>
<!-- Tab 0: Overview -->
@@ -225,6 +228,13 @@
style="width: 100%; max-width: 600px; margin-top: 5px; font-family: monospace;"
placeholder="/streaming/account/*/device/*/recent&#10;/accounts/*/devices/*/presets/*"
></textarea>
<label for="skip-mirror-endpoints" style="display: block; margin-top: 10px;">Skip Mirror Endpoints (local only, one per line):</label>
<textarea
id="skip-mirror-endpoints"
rows="2"
style="width: 100%; max-width: 600px; margin-top: 5px; font-family: monospace;"
placeholder="/oauth/device/*/music/musicprovider/*/token/cs3&#10;/oauth/device/*/music/musicprovider/*/token"
></textarea>
<div
class="info-box"
style="
@@ -1214,7 +1224,7 @@
id="interaction-content"
style="
white-space: pre-wrap;
font-family: &quot;Courier New&quot;, Courier, monospace;
font-family: 'Courier New', Courier, monospace;
font-size: 0.9em;
margin: 0;
padding: 10px;
@@ -1434,6 +1444,30 @@
</div>
</div>
</div>
<!-- Tab 7: Local Account -->
<div id="tab-account" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2>Local Account Details</h2>
<div style="display: flex; gap: 10px; align-items: center;">
<label for="account-selector">Account:</label>
<select id="account-selector" onchange="fetchAccountDetails(this.value)">
<option value="default">Default</option>
</select>
<button onclick="fetchAccountDetails(document.getElementById('account-selector').value)">Refresh</button>
</div>
</div>
<div id="account-info-container" class="summary-box">
<h3>Account Overview</h3>
<div id="account-metadata">Loading...</div>
</div>
<div id="account-devices-container">
<h3>Connected Devices</h3>
<div id="account-devices-list">Select an account to view devices.</div>
</div>
</div>
</div>
<script src="/web/js/script.js"></script>
+302
View File
@@ -158,6 +158,9 @@ async function fetchSettings() {
if (settings.mirror_endpoints) {
document.getElementById("mirror-endpoints").value = settings.mirror_endpoints.join("\n");
}
if (settings.skip_mirror_endpoints) {
document.getElementById("skip-mirror-endpoints").value = settings.skip_mirror_endpoints.join("\n");
}
if (settings.internal_paths) {
document.getElementById("internal-paths").value = settings.internal_paths.join("\n");
}
@@ -220,6 +223,11 @@ async function updateSettings() {
.value.split("\n")
.map((s) => s.trim())
.filter((s) => s !== ""),
skip_mirror_endpoints: document
.getElementById("skip-mirror-endpoints")
.value.split("\n")
.map((s) => s.trim())
.filter((s) => s !== ""),
internal_paths: document
.getElementById("internal-paths")
.value.split("\n")
@@ -360,6 +368,10 @@ function openTab(evt, tabId) {
fetchParityMismatches();
}
if (tabId === "tab-account") {
fetchAccountList();
}
if (evt) {
evt.currentTarget.className += " active";
} else {
@@ -459,6 +471,296 @@ async function fetchVersion() {
}
}
async function fetchAccountList() {
try {
const response = await fetch("/mgmt/accounts");
if (!response.ok) return;
const data = await response.json();
const selector = document.getElementById("account-selector");
if (selector) {
selector.innerHTML = data.accounts.map(acc => `<option value="${acc}">${acc}</option>`).join("");
if (data.accounts.length > 0) {
fetchAccountDetails(selector.value);
}
}
} catch (error) {
console.error("Failed to fetch account list", error);
}
}
async function fetchAccountDetails(accountId) {
if (!accountId) return;
const metadataEl = document.getElementById("account-metadata");
const devicesEl = document.getElementById("account-devices-list");
if (metadataEl) metadataEl.innerHTML = "Loading...";
if (devicesEl) devicesEl.innerHTML = "Loading devices...";
try {
const response = await fetch(`/mgmt/accounts/${encodeURIComponent(accountId)}`);
if (!response.ok) {
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Failed to load account details: ${response.statusText}</span>`;
return;
}
const data = await response.json();
// Render Metadata
if (metadataEl) {
const warningNotice = data.account.is_placeholder ?
`<div style="background: #fff3cd; color: #856404; padding: 10px; border: 1px solid #ffeeba; border-radius: 4px; margin-bottom: 10px; font-size: 0.85em;">
<strong>Notice:</strong> Account data (account.json) was not found in the expected location for this account ID.
</div>` : "";
metadataEl.innerHTML = `
${warningNotice}
<table style="width: 100%; font-size: 0.9em;">
<tr><td style="padding: 4px"><strong>Account ID:</strong></td><td style="padding: 4px">${data.account.account_id}</td></tr>
<tr><td style="padding: 4px"><strong>Language:</strong></td><td style="padding: 4px">
<select id="account-language-select" style="font-size: 0.9em; padding: 2px;">
<option value="en" ${data.account.preferred_language === "en" || !data.account.preferred_language ? "selected" : ""}>en</option>
<option value="de" ${data.account.preferred_language === "de" ? "selected" : ""}>de</option>
</select>
<span id="language-update-status" style="margin-left: 8px; font-size: 0.8em; display: none;">Saving...</span>
</td></tr>
<tr><td style="padding: 4px"><strong>Provider Settings:</strong></td><td style="padding: 4px">
${data.account.provider_settings && data.account.provider_settings.length > 0 ?
(() => {
const grouped = data.account.provider_settings.reduce((acc, s) => {
const pName = s.provider_name || s.provider_id;
if (!acc[pName]) acc[pName] = [];
acc[pName].push(s);
return acc;
}, {});
return Object.entries(grouped).map(([pName, settings]) => `
<div style="margin-bottom: 8px;">
<strong>${pName}</strong>
<ul style="margin: 2px 0 0 0; padding-left: 20px; list-style-type: disc;">
${settings.map(s => {
if ((s.provider_name === "SPOTIFY" || s.provider_id === "15") && s.key_name === "STREAMING_QUALITY") {
return `
<li style="margin-bottom: 4px;">
Music Streaming Quality:
<select class="provider-setting-select"
data-account-id="${data.account.account_id}"
data-provider-id="${s.provider_id}"
data-key="${s.key_name}"
style="font-size: 0.9em; padding: 2px; margin-left: 4px;">
<option value="1" ${s.value === "1" ? "selected" : ""}>Fastest Streaming - up to 128 kbit/s</option>
<option value="2" ${s.value === "2" ? "selected" : ""}>Balanced Quality and Speed - up to 192 kbit/s</option>
<option value="3" ${s.value === "3" ? "selected" : ""}>Best Quality - up to 320 kbit/s</option>
</select>
<span class="setting-update-status" style="margin-left: 8px; font-size: 0.8em; display: none;">Saving...</span>
</li>
`;
}
return `<li>${s.key_name}: ${s.value}</li>`;
}).join("")}
</ul>
</div>
`).join("");
})() : "None"}
</td></tr>
</table>
`;
const languageSelect = document.getElementById("account-language-select");
if (languageSelect) {
languageSelect.addEventListener("change", async (e) => {
const statusEl = document.getElementById("language-update-status");
const newLang = e.target.value;
if (statusEl) {
statusEl.innerText = "Saving...";
statusEl.style.display = "inline";
statusEl.style.color = "#666";
}
try {
const response = await fetch(`/mgmt/accounts/${data.account.account_id}/language`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ language: newLang }),
});
if (response.ok) {
if (statusEl) {
statusEl.innerText = "Saved!";
statusEl.style.color = "#28a745";
setTimeout(() => {
statusEl.style.display = "none";
}, 2000);
}
} else {
throw new Error(await response.text());
}
} catch (error) {
console.error("Failed to update language", error);
if (statusEl) {
statusEl.innerText = "Error!";
statusEl.style.color = "#dc3545";
}
}
});
}
const providerSettingSelects = document.querySelectorAll(".provider-setting-select");
providerSettingSelects.forEach(select => {
select.addEventListener("change", async (e) => {
const statusEl = e.target.parentElement.querySelector(".setting-update-status");
const accID = e.target.dataset.accountId;
const provID = e.target.dataset.providerId;
const key = e.target.dataset.key;
const newValue = e.target.value;
if (statusEl) {
statusEl.innerText = "Saving...";
statusEl.style.display = "inline";
statusEl.style.color = "#666";
}
try {
const response = await fetch(`/mgmt/accounts/${accID}/provider-settings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
provider_id: provID,
key: key,
value: newValue
}),
});
if (response.ok) {
if (statusEl) {
statusEl.innerText = "Saved!";
statusEl.style.color = "#28a745";
setTimeout(() => {
statusEl.style.display = "none";
}, 2000);
}
} else {
throw new Error(await response.text());
}
} catch (error) {
console.error("Failed to update provider setting", error);
if (statusEl) {
statusEl.innerText = "Error!";
statusEl.style.color = "#dc3545";
}
}
});
});
}
// Render Devices
if (devicesEl) {
if (!data.devices || data.devices.length === 0) {
devicesEl.innerHTML = "No devices found for this account.";
return;
}
devicesEl.innerHTML = data.devices.map(device => `
<div class="summary-box" style="margin-bottom: 15px; border-left: 5px solid #007bff; padding: 15px;">
<div style="display: flex; justify-content: space-between; cursor: pointer; align-items: center;" onclick="toggleInfo('device-details-${device.device_id}')">
<h4 style="margin: 0">${device.name || "Unnamed Device"} (${device.product_code})</h4>
<div style="font-size: 0.8em; color: #666">
${device.ip_address} | ${device.device_id} <span style="font-size: 1.2em; vertical-align: middle;">&#9662;</span>
</div>
</div>
<div id="device-details-${device.device_id}" style="display: none; margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px">
<div>
<h5 style="margin: 10px 0 5px 0">Device Metadata</h5>
<div style="font-size: 0.85em; background: #f8f9fa; padding: 8px; border-radius: 4px; border: 1px solid #e9ecef">
<strong>Serial:</strong> ${device.device_serial_number || device.serial_number || "N/A"}<br>
<strong>MAC:</strong> ${device.mac_address || "N/A"}<br>
<strong>Version:</strong> ${device.firmware_version || "N/A"}<br>
<strong>Discovery:</strong> ${device.discovery_method || "N/A"}
</div>
<h5 style="margin: 15px 0 5px 0">Hardware Components</h5>
<ul style="font-size: 0.8em; padding-left: 20px; margin: 0">
${device.components ? device.components.map(c => `<li><strong>${c.category || c.type || 'Component'}</strong>: ${c.firmware_version || 'N/A'} <br><small style="color:#777">S/N: ${c.serial_number || 'N/A'}</small></li>`).join("") : "<li>No components found</li>"}
</ul>
</div>
<div>
<h5 style="margin: 10px 0 5px 0">Presets (1-6)</h5>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 5px">
${Array.from({length: 6}, (_, i) => {
const p = device.presets ? device.presets.find(pr => pr.button_number == i + 1) : null;
let itemName = "Empty";
let sourceLabel = "";
if (p) {
itemName = p.name || (p.source ? (p.source.source_label || p.source.name || p.source.type) : "Unknown");
if (p.source) {
const s = p.source;
const name = s.source_label || s.source_name || s.name || s.type;
const account = (s.account && s.account !== s.username) ? ` [${s.account}]` : "";
const finalName = name || s.type || "Unknown Source";
if (finalName) {
sourceLabel = `<br><small style="color: #666; font-size: 0.85em;">via ${finalName}${account}</small>`;
}
}
}
return `
<div style="border: 1px solid #ddd; padding: 5px; font-size: 0.8em; background: ${p ? "#e6ffed" : "#f8f9fa"}; border-radius: 3px;">
<strong>#${i + 1}</strong>: ${itemName}${sourceLabel}
</div>
`;
}).join("")}
</div>
<h5 style="margin: 15px 0 5px 0">Recent Items</h5>
<div style="max-height: 150px; overflow-y: auto; font-size: 0.8em; border: 1px solid #eee; padding: 5px; border-radius: 4px">
<ul style="padding-left: 15px; margin: 0">
${device.recents ? device.recents.slice(0, 10).map(r => {
const name = r.name || (r.source ? (r.source.source_label || r.source.name || r.source.type) : "Unknown");
let sourceLabel = "";
if (r.source) {
const s = r.source;
const sName = s.source_label || s.source_name || s.name || s.type;
const account = (s.account && s.account !== s.username) ? ` [${s.account}]` : "";
const finalSName = sName || s.type || "Unknown Source";
if (finalSName) {
sourceLabel = `<br><small style="color: #666; font-size: 0.9em;">via ${finalSName}${account}</small>`;
}
}
return `<li>${name}${sourceLabel} <br><small style="color:#888">${r.created_on ? new Date(r.created_on * 1000).toLocaleString() : 'N/A'}</small></li>`;
}).join("") : "<li>No recents</li>"}
</ul>
</div>
</div>
</div>
<div style="margin-top: 15px; border-top: 1px dashed #ddd; padding-top: 10px">
<h5 style="margin: 0 0 5px 0">Configured Sources</h5>
<div style="display: flex; flex-wrap: wrap; gap: 5px">
${device.sources ? device.sources.filter(s => (s.source_label || s.source_name || s.name || s.type)).map(s => {
const sourceName = s.source_label || s.source_name || s.name || s.type;
const usernameSuffix = (s.username && s.username !== "Local") ? ` (${s.username})` : "";
const accountSuffix = (s.account && s.account !== s.username) ? ` [${s.account}]` : "";
return `
<span style="background: #eefbff; color: #0056b3; border: 1px solid #b8daff; padding: 2px 8px; border-radius: 12px; font-size: 0.75em" title="Source Type: ${s.type}">
${sourceName}${usernameSuffix}${accountSuffix}
</span>
`;
}).join("") : "<small style='color:#999'>None</small>"}
</div>
</div>
</div>
</div>
`).join("");
}
} catch (error) {
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Error: ${error.message}</span>`;
console.error("Failed to fetch account details", error);
}
}
async function fetchInteractionStats() {
console.log("Fetching interaction stats...");
try {
+86 -103
View File
@@ -62,79 +62,12 @@ func SourceProvidersToXML() ([]byte, error) {
// ConfiguredSourceToXML converts a configured source to XML format.
func ConfiguredSourceToXML(cs models.ConfiguredSource) ([]byte, error) {
type SourceXML struct {
XMLName xml.Name `xml:"source"`
ID string `xml:"id,attr"`
Type string `xml:"type,attr"`
CreatedOn string `xml:"createdOn"`
Credential struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
} `xml:"credential"`
Name string `xml:"name"`
SourceProviderID string `xml:"sourceproviderid"`
SourceName string `xml:"sourcename"`
SourceSettings string `xml:"sourceSettings"`
UpdatedOn string `xml:"updatedOn"`
Username string `xml:"username"`
}
providerID := cs.SourceProviderID
tokenType := "token"
if providerID == "" {
for _, p := range constants.StaticProviders {
if p.Name == cs.SourceKeyType {
providerID = strconv.Itoa(p.ID)
break
}
}
}
if cs.SourceKeyType == "SPOTIFY" {
tokenType = "token_version_3"
}
if providerID == "" {
providerID = "0"
}
createdOn := cs.CreatedOn
if createdOn == "" {
createdOn = DateStr
}
updatedOn := cs.UpdatedOn
if updatedOn == "" {
updatedOn = DateStr
}
sxml := SourceXML{
ID: cs.ID,
Type: "Audio",
CreatedOn: createdOn,
Name: cs.SourceKeyAccount,
SourceProviderID: providerID,
SourceName: cs.DisplayName,
SourceSettings: "",
UpdatedOn: updatedOn,
Username: cs.SourceKeyAccount,
}
if sxml.SourceName == "Other" || cs.SourceKeyType == "TUNEIN" {
sxml.SourceName = ""
}
sxml.Credential.Type = tokenType
sxml.Credential.Value = cs.Secret
data, err := xml.Marshal(sxml)
// Use the model's own MarshalXML for consistent output
data, err := xml.Marshal(cs)
if err != nil {
return nil, err
}
// Parity: use self-closing tags for empty SourceSettings
data = bytes.ReplaceAll(data, []byte("<sourceSettings></sourceSettings>"), []byte("<sourceSettings/>"))
return data, nil
}
@@ -240,9 +173,17 @@ func PresetsToXML(ds *datastore.DataStore, account, deviceID string) ([]byte, er
}
// Find and prepare source
// Priority 1: sourceID match
// Priority 2: source and sourceAccount match
sourceID := p.SourceID
if sourceID == "" {
sourceID = p.SourceID
}
for j := range sources {
s := sources[j]
if s.ID == p.SourceID || (s.SourceKeyType == p.Source && s.SourceKeyAccount == p.SourceAccount) {
if (sourceID != "" && s.ID == sourceID) ||
(s.SourceKeyType == p.Source && s.SourceKeyAccount == p.SourceAccount) {
// Use a new variable to avoid pointer-to-iterator-variable bug
matchedSource := s
PrepareConfiguredSource(&matchedSource)
@@ -255,12 +196,12 @@ func PresetsToXML(ds *datastore.DataStore, account, deviceID string) ([]byte, er
pxml.Presets = append(pxml.Presets, p)
}
data, err := xml.Marshal(pxml)
data, err := xml.MarshalIndent(pxml, "", " ")
if err != nil {
return nil, err
}
return append([]byte(constants.XMLHeader), data...), nil
return append([]byte(constants.XMLHeader+"\n"), data...), nil
}
// RecentsToXML converts account recent items to XML format for Marge responses.
@@ -372,11 +313,20 @@ func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (mod
UpdatedOn: DateStr,
}
if device.SerialNumber == "" && info.DeviceID != "" {
device.SerialNumber = info.DeviceID
}
if device.AttachedProduct.SerialNumber == "" && info.ProductSerialNumber != "" {
device.AttachedProduct.SerialNumber = info.ProductSerialNumber
} else if device.AttachedProduct.SerialNumber == "" && device.SerialNumber != "" {
device.AttachedProduct.SerialNumber = device.SerialNumber
}
if len(info.Components) > 0 {
for _, comp := range info.Components {
device.AttachedProduct.Components = append(device.AttachedProduct.Components, models.ServiceComponent{
Type: comp.Type,
Label: comp.Label,
Category: comp.Category,
SoftwareVersion: comp.SoftwareVersion,
SerialNumber: comp.SerialNumber,
})
@@ -397,6 +347,7 @@ func mapToFullResponseSource(s models.ConfiguredSource) models.FullResponseSourc
fullSource := models.FullResponseSource{
ID: s.ID,
Type: s.Type,
DisplayName: s.DisplayName,
CreatedOn: s.CreatedOn,
Name: s.SourceKeyAccount,
SourceProviderID: s.SourceProviderID,
@@ -456,7 +407,7 @@ func mapPresetsToFullResponse(presets []models.ServicePreset, sources []models.C
}
fullPreset := models.FullResponsePreset{
ButtonNumber: p.ID,
ButtonNumber: p.ButtonNumber,
ContainerArt: p.ContainerArt,
ContentItemType: p.ContentItemType,
CreatedOn: p.CreatedOn,
@@ -535,21 +486,43 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
ID: account,
AccountStatus: "OK",
Mode: "global",
PreferredLanguage: "en",
ProviderSettings: []models.ProviderSetting{
{
PreferredLanguage: "de",
}
for _, p := range constants.StaticProviders {
switch p.Name {
case "DEEZER":
resp.ProviderSettings = append(resp.ProviderSettings, models.ProviderSetting{
BoseID: account,
KeyName: "ELIGIBLE_FOR_TRIAL",
Value: "false",
ProviderID: "14",
},
{
ProviderID: strconv.Itoa(p.ID),
})
case "SPOTIFY":
resp.ProviderSettings = append(resp.ProviderSettings, models.ProviderSetting{
BoseID: account,
KeyName: "STREAMING_QUALITY",
Value: "2",
ProviderID: "15",
},
},
ProviderID: strconv.Itoa(p.ID),
})
}
}
if info, _ := ds.GetAccountInfo(account); info != nil {
if info.PreferredLanguage != "" {
resp.PreferredLanguage = info.PreferredLanguage
}
if len(info.ProviderSettings) > 0 {
resp.ProviderSettings = info.ProviderSettings
}
}
for i := range resp.ProviderSettings {
ps := &resp.ProviderSettings[i]
if ps.ProviderName == "" {
ps.ProviderName = constants.GetProviderName(ps.ProviderID)
}
}
var lastDeviceID string
@@ -635,14 +608,15 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
nowStr := strconv.FormatInt(time.Now().Unix(), 10)
presetObj := models.ServicePreset{
ServiceContentItem: models.ServiceContentItem{
ID: strconv.Itoa(presetNumber),
Name: newPresetElem.Name,
Source: matchingSrc.SourceKeyType,
Type: newPresetElem.ContentItemType,
Location: newPresetElem.Location,
SourceAccount: matchingSrc.SourceKeyAccount,
SourceID: newPresetElem.SourceID,
Name: newPresetElem.Name,
Source: matchingSrc.SourceKeyType,
Type: newPresetElem.ContentItemType,
Location: newPresetElem.Location,
SourceAccount: matchingSrc.SourceKeyAccount,
SourceID: newPresetElem.SourceID,
ContentItemType: newPresetElem.ContentItemType,
},
ID: strconv.Itoa(presetNumber),
ContainerArt: newPresetElem.ContainerArt,
CreatedOn: nowStr,
UpdatedOn: nowStr,
@@ -732,8 +706,11 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte
}
// Ensure DisplayName and SourceName are consistent
if matchingSrc.SourceName == "" && matchingSrc.DisplayName != "" && matchingSrc.DisplayName != "Other" {
matchingSrc.SourceName = matchingSrc.DisplayName
if matchingSrc.SourceName == "" && matchingSrc.DisplayName != "" {
// Parity: for some services like TuneIn, sourcename should be empty
if matchingSrc.DisplayName != "TuneIn" && matchingSrc.DisplayName != "Other" {
matchingSrc.SourceName = matchingSrc.DisplayName
}
}
if matchingSrc.DisplayName == "" && matchingSrc.SourceName != "" {
@@ -770,8 +747,16 @@ func learnSource(ds *datastore.DataStore, account, device string, sources []mode
func createLearnedSource(sourceID, location, sourceName, credentialValue, sourceProviderID, createdOn, updatedOn string) *models.ConfiguredSource {
displayName := sourceName
if displayName == "" {
displayName = "Other"
// For TuneIn, we often see empty DisplayName/SourceName in recent items
// if it's already a known source or if it's a generic TuneIn request.
if displayName == "" && sourceID != "" {
// Try to deduce from sourceID if it looks like a known service
switch sourceID {
case "14774275": // TuneIn
displayName = "TuneIn"
case "Spotify":
displayName = "Spotify"
}
}
src := &models.ConfiguredSource{
@@ -873,18 +858,16 @@ func updateOrCreateRecent(recents []models.ServiceRecent, name string, matchingS
// Move to front
recents = append([]models.ServiceRecent{*recentObj}, append(recents[:i], recents[i+1:]...)...)
break
return recentObj, recents
}
}
if recentObj == nil {
recentObj = createNewRecent(recents, name, matchingSrc, contentItemType, location, device, utcTime)
recentObj.UpdatedOn = FormatTime(time.Now())
recentObj = createNewRecent(recents, name, matchingSrc, contentItemType, location, device, utcTime)
recentObj.UpdatedOn = FormatTime(time.Now())
recents = append([]models.ServiceRecent{*recentObj}, recents...)
if len(recents) > 10 {
recents = recents[:10]
}
recents = append([]models.ServiceRecent{*recentObj}, recents...)
if len(recents) > 10 {
recents = recents[:10]
}
return recentObj, recents
+109 -86
View File
@@ -88,7 +88,7 @@ func TestAccountFullToXML_Structure(t *testing.T) {
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
account := "3230304"
account := "1234567"
device := "08DF1F0BA325"
// 1. Setup Device Info with Components
@@ -100,20 +100,20 @@ func TestAccountFullToXML_Structure(t *testing.T) {
ProductSerialNumber: "066802942560222AE",
FirmwareVersion: "27.0.6.46330.5043500",
IPAddress: "192.168.178.28",
}
_ = ds.SaveDeviceInfo(account, device, info)
// Since SaveDeviceInfo is limited, we'll manually add the SMSC component
// because CreateAccountDevice expects it in info.Components
info, _ = ds.GetDeviceInfo(account, device)
info.Components = []models.ServiceComponent{
{
Type: "SMSC",
SoftwareVersion: "I2014101420409423",
SerialNumber: "08DF1F0BA32A",
Label: "SMSC",
Components: []models.ServiceComponent{
{
Category: "SMSC",
SoftwareVersion: "I2014101420409423",
SerialNumber: "08DF1F0BA32A",
},
{
Category: "LIGHTSWITCH",
SoftwareVersion: "1.2.3",
SerialNumber: "LS001",
},
},
}
_ = ds.SaveDeviceInfo(account, device, info)
// We'll mock the CreateAccountDevice call or just rely on the fact that
// info.Components will be used if we could save it.
// But ds.SaveDeviceInfo doesn't save arbitrary components.
@@ -127,26 +127,27 @@ func TestAccountFullToXML_Structure(t *testing.T) {
// 2. Setup Sources
src := models.ConfiguredSource{
ID: "10863533",
DisplayName: "gesellix",
DisplayName: "test-user",
Type: "Audio",
Secret: "AQBtotl13...",
Secret: "dummy-token-spotify...",
SecretType: "token_version_3",
SourceName: "gesellix+spotify@gmail.com",
Username: "gesellix",
SourceName: "test-user+spotify@gmail.com",
Username: "test-user",
}
src.SourceKeyType = "SPOTIFY"
src.SourceKeyAccount = "gesellix"
src.SourceKeyAccount = "test-user"
_ = ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{src})
// 3. Setup Presets
preset := models.ServicePreset{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "Jonas",
Name: "test-playlist",
Type: "tracklisturl",
Location: "/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh",
Source: "SPOTIFY",
},
ID: "1",
ButtonNumber: "1",
ContainerArt: "https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b",
}
_ = ds.SavePresets(account, device, []models.ServicePreset{preset})
@@ -173,8 +174,11 @@ func TestAccountFullToXML_Structure(t *testing.T) {
// 6. Verify Structure
// Root and attributes
if !strings.Contains(xmlStr, `<account id="3230304">`) {
t.Errorf("Expected <account id=\"3230304\">, got %s", xmlStr)
if !strings.Contains(xmlStr, `<account id="1234567">`) {
t.Errorf("Expected <account id=\"1234567\">, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<preferredLanguage>de</preferredLanguage>`) {
t.Errorf("Expected <preferredLanguage>de</preferredLanguage>, got %s", xmlStr)
}
// Device structure
@@ -191,15 +195,29 @@ func TestAccountFullToXML_Structure(t *testing.T) {
t.Errorf("Expected <updatedOn> under device, got %s", xmlStr)
}
// Preset buttonNumber
if !strings.Contains(xmlStr, `<preset buttonNumber="1">`) {
t.Errorf("Expected <preset buttonNumber=\"1\">, got %s", xmlStr)
}
// AttachedProduct and Components
if !strings.Contains(xmlStr, `<attachedProduct product_code="SoundTouch 20">`) {
t.Errorf("Expected attachedProduct with product_code, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<productlabel>SoundTouch 20</productlabel>`) {
t.Errorf("Expected productlabel SoundTouch 20, got %s", xmlStr)
t.Errorf("Expected productlabel, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<serialnumber>066802942560222AE</serialnumber>`) {
t.Errorf("Expected <serialnumber>066802942560222AE</serialnumber> under attachedProduct, got %s", xmlStr)
if !strings.Contains(xmlStr, `<component category="SMSC">`) && !strings.Contains(xmlStr, `category="SMSC"`) {
t.Errorf("Expected component with category SMSC, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<component category="LIGHTSWITCH">`) && !strings.Contains(xmlStr, `category="LIGHTSWITCH"`) {
t.Errorf("Expected component with category LIGHTSWITCH, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<firmware-version>1.2.3</firmware-version>`) {
t.Errorf("Expected firmware-version 1.2.3, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<serialnumber>08DF1F0BA325</serialnumber>`) {
t.Errorf("Expected <serialnumber>08DF1F0BA325</serialnumber> under attachedProduct, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<updatedOn>`) {
t.Errorf("Expected <updatedOn> under attachedProduct, got %s", xmlStr)
@@ -225,10 +243,13 @@ func TestAccountFullToXML_Structure(t *testing.T) {
}
// Global Sources
if !strings.Contains(xmlStr, `<source id="10863533" type="Audio">`) {
t.Errorf("Expected source tag with attributes, got %s", xmlStr)
if !strings.Contains(xmlStr, `<source id="10863533" type="Audio" displayName="test-user">`) {
t.Errorf("Expected source tag with displayName attribute, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<credential type="token_version_3">AQBtotl13...</credential>`) {
if !strings.Contains(xmlStr, `<name>test-user</name>`) {
t.Errorf("Expected <name>test-user</name> under source, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<credential type="token_version_3">dummy-token-spotify...</credential>`) {
t.Errorf("Expected credential tag, got %s", xmlStr)
}
@@ -304,11 +325,11 @@ func TestRecentsXML_EmptyIDFix(t *testing.T) {
t.Fatalf("RecentsToXML failed: %v", err)
}
if strings.Contains(string(xmlData), `recent id=""`) {
if strings.Contains(string(xmlData), ` id=""`) {
t.Errorf("XML should not contain empty recent ID: %s", string(xmlData))
}
if !strings.Contains(string(xmlData), `recent id="1"`) {
if !strings.Contains(string(xmlData), `id="1"`) {
t.Errorf("XML should contain fixed numeric ID: %s", string(xmlData))
}
}
@@ -331,11 +352,14 @@ func TestRecentsToXML_SourceIncluded(t *testing.T) {
recents := []models.ServiceRecent{
{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "Test Track",
SourceID: "100001",
Type: "tracklisturl",
Location: "/test",
ID: "1",
Name: "Test Track",
Source: "SPOTIFY",
SourceAccount: "test-user",
SourceID: "100001",
Type: "tracklisturl",
ContentItemType: "tracklisturl",
Location: "/test",
},
DeviceID: device,
UtcTime: "1708896000",
@@ -350,6 +374,10 @@ func TestRecentsToXML_SourceIncluded(t *testing.T) {
DisplayName: "Spotify",
SourceName: "Spotify",
Username: "testuser",
SourceKey: struct {
Type string `xml:"type,attr"`
Account string `xml:"account,attr"`
}{Type: "SPOTIFY", Account: "test-user"},
},
}
_ = ds.SaveConfiguredSources(account, device, sources)
@@ -361,14 +389,20 @@ func TestRecentsToXML_SourceIncluded(t *testing.T) {
}
xmlStr := string(xmlData)
if !strings.Contains(xmlStr, "<source") {
t.Errorf("XML should contain <source> element: %s", xmlStr)
if !strings.Contains(xmlStr, "id=\"1\"") {
t.Errorf("XML should contain id=\"1\" for recent: %s", xmlStr)
}
if !strings.Contains(xmlStr, "<sourcename>Spotify</sourcename>") {
t.Errorf("XML should contain <sourcename>Spotify</sourcename>: %s", xmlStr)
if !strings.Contains(xmlStr, "source=\"SPOTIFY\"") {
t.Errorf("XML should contain source=\"SPOTIFY\" attribute: %s", xmlStr)
}
if !strings.Contains(xmlStr, "<username>testuser</username>") {
t.Errorf("XML should contain <username>testuser</username>: %s", xmlStr)
if !strings.Contains(xmlStr, "type=\"tracklisturl\"") {
t.Errorf("XML should contain type=\"tracklisturl\" attribute: %s", xmlStr)
}
if !strings.Contains(xmlStr, "location=\"/test\"") {
t.Errorf("XML should contain location=\"/test\" attribute: %s", xmlStr)
}
if !strings.Contains(xmlStr, "displayName=\"Spotify\"") {
t.Errorf("XML should contain displayName=\"Spotify\" in source attribute: %s", xmlStr)
}
}
@@ -390,12 +424,15 @@ func TestPresetsToXML_SourceIncluded(t *testing.T) {
presets := []models.ServicePreset{
{
ServiceContentItem: models.ServiceContentItem{
ID: "1",
Name: "Test Preset",
SourceID: "100001",
Type: "tracklisturl",
Location: "/test",
ID: "1",
Name: "Test Preset",
SourceID: "100001",
Source: "SPOTIFY",
SourceAccount: "testuser",
Type: "tracklisturl",
Location: "/test",
},
ID: "1",
},
}
_ = ds.SavePresets(account, device, presets)
@@ -405,10 +442,10 @@ func TestPresetsToXML_SourceIncluded(t *testing.T) {
{
ID: "100001",
DisplayName: "Spotify",
SourceName: "Spotify",
Username: "testuser",
},
}
sources[0].SourceKey.Type = "SPOTIFY"
sources[0].SourceKey.Account = "testuser"
_ = ds.SaveConfiguredSources(account, device, sources)
// Fetch XML
@@ -421,8 +458,8 @@ func TestPresetsToXML_SourceIncluded(t *testing.T) {
if !strings.Contains(xmlStr, "<source") {
t.Errorf("XML should contain <source> element: %s", xmlStr)
}
if !strings.Contains(xmlStr, "<sourcename>Spotify</sourcename>") {
t.Errorf("XML should contain <sourcename>Spotify</sourcename>: %s", xmlStr)
if !strings.Contains(xmlStr, "displayName=\"Spotify\"") {
t.Errorf("XML should contain displayName=\"Spotify\" attribute: %s", xmlStr)
}
}
@@ -431,6 +468,7 @@ func TestGetConfiguredSourceXML_Escaping(t *testing.T) {
ID: "101&202",
DisplayName: "Test & Source",
Secret: "key&value",
SecretType: "token",
}
src.SourceKeyAccount = "user&name"
@@ -438,36 +476,23 @@ func TestGetConfiguredSourceXML_Escaping(t *testing.T) {
if !strings.Contains(xmlData, "id=\"101&amp;202\"") {
t.Errorf("ID not escaped in attribute: %s", xmlData)
}
if strings.Contains(xmlData, "<sourceid>101&amp;202</sourceid>") {
t.Errorf("ID should not be escaped in sourceid tag inside source tag anymore: %s", xmlData)
if !strings.Contains(xmlData, "displayName=\"Test &amp; Source\"") {
t.Errorf("DisplayName not escaped in attribute: %s", xmlData)
}
if !strings.Contains(xmlData, "<sourcename>Test &amp; Source</sourcename>") {
t.Errorf("DisplayName not escaped: %s", xmlData)
}
if !strings.Contains(xmlData, ">key&amp;value</credential>") {
t.Errorf("Secret not escaped: %s", xmlData)
if !strings.Contains(xmlData, "secret=\"key&amp;value\"") {
t.Errorf("Secret not escaped in attribute: %s", xmlData)
}
}
func TestGetConfiguredSourceXML_Parity(t *testing.T) {
t.Run("Other source should have empty sourcename", func(t *testing.T) {
t.Run("Other source should have displayName in attribute", func(t *testing.T) {
src := models.ConfiguredSource{
ID: "14774275",
DisplayName: "Other",
}
xmlData := GetConfiguredSourceXML(src)
if !strings.Contains(xmlData, "<sourcename></sourcename>") && !strings.Contains(xmlData, "<sourcename/>") {
t.Errorf("Expected empty sourcename for 'Other', got: %s", xmlData)
}
})
t.Run("sourceSettings should be present", func(t *testing.T) {
src := models.ConfiguredSource{
ID: "14774275",
}
xmlData := GetConfiguredSourceXML(src)
if !strings.Contains(xmlData, "<sourceSettings>") && !strings.Contains(xmlData, "<sourceSettings/>") {
t.Errorf("Expected sourceSettings, got: %s", xmlData)
if !strings.Contains(xmlData, "displayName=\"Other\"") {
t.Errorf("Expected displayName=\"Other\", got: %s", xmlData)
}
})
}
@@ -504,10 +529,10 @@ func TestAddRecent_TimestampPreservation(t *testing.T) {
// 2. Add an initial recent
sourceXML := []byte(`
<recent>
<name>Initial Station</name>
<contentItem source="TUNEIN" type="stationurl" location="station-1" sourceAccount="test-user">
<itemName>Initial Station</itemName>
</contentItem>
<sourceid>101</sourceid>
<location>station-1</location>
<contentItemType>station</contentItemType>
</recent>`)
_, err = AddRecent(ds, account, device, sourceXML)
@@ -535,16 +560,14 @@ func TestAddRecent_TimestampPreservation(t *testing.T) {
}
recents, _ = ds.GetRecents(account, device)
// AddRecent should have reused the existing one since location/source are the same
if len(recents) != 1 {
t.Errorf("Expected still 1 recent, got %d", len(recents))
}
// Verify that sourceid is present in recent response and is a sibling to source tag
if !strings.Contains(string(respXML), "<sourceid>101</sourceid>") {
t.Errorf("Expected sourceid in recent response: %s", string(respXML))
}
if strings.Contains(string(respXML), "<source id=\"101\" type=\"Audio\"><createdOn>2012-09-19T12:43:00.000+00:00</createdOn><credential type=\"token\">key&amp;value</credential><name>test-user</name><sourceid>101</sourceid>") {
t.Errorf("sourceid should not be inside source tag: %s", string(respXML))
// Verify that source id is present in recent response
if !strings.Contains(string(respXML), "id=\"101\"") {
t.Errorf("Expected source id in recent response: %s", string(respXML))
}
}
@@ -604,15 +627,15 @@ func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
}
defer func() { _ = os.RemoveAll(tempDir) }()
account := "3230304"
device := "A81B6A536A98"
account := "1234567"
device := "001122334455"
// Mimic the backup structure: accounts/3230304/devices/A81B6A536A98/DeviceInfo.xml
// Mimic the backup structure: accounts/1234567/devices/001122334455/DeviceInfo.xml
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
_ = os.MkdirAll(deviceDir, 0755)
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="A81B6A536A98">
<info deviceID="001122334455">
<name>Sound Machinechen</name>
<type>SoundTouch</type>
<moduleType>10 sm2</moduleType>
@@ -625,7 +648,7 @@ func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
</components>
<networkInfo type="SCM">
<ipAddress>192.168.178.35</ipAddress>
<macAddress>A81B6A536A98</macAddress>
<macAddress>001122334455</macAddress>
</networkInfo>
<discoveryMethod>sync_full</discoveryMethod>
</info>`
@@ -652,7 +675,7 @@ func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
<presets>
<preset id="1" createdOn="1719128436" updatedOn="1728740382">
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh" itemName="Jonas" isPresetable="true" contentItemType="tracklisturl">
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh" itemName="test-playlist" isPresetable="true" contentItemType="tracklisturl">
<containerArt>https://i.scdn.co/image/art</containerArt>
</ContentItem>
</preset>
@@ -670,7 +693,7 @@ func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
}
// 3. Test with empty name
_ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0" encoding="UTF-8"?><info deviceID="A81B6A536A98"><name></name></info>`), 0644)
_ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0" encoding="UTF-8"?><info deviceID="001122334455"><name></name></info>`), 0644)
fullXML2, _ := AccountFullToXML(ds, account)
if !strings.Contains(string(fullXML2), `<name/>`) {
t.Errorf("Expected <name/> for empty name, got %s", string(fullXML2))
+379 -16
View File
@@ -7,9 +7,81 @@ import (
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestReadSourcesWithEmptyDisplayName(t *testing.T) {
tempBaseDir := "repro_sources_data"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
accountID := "1234567"
deviceID := "001122334455"
// Create device directory
devDir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", deviceID)
err = os.MkdirAll(devDir, 0755)
if err != nil {
t.Fatal(err)
}
// Create DeviceInfo.xml so ListAllDevices finds it
devInfo := `<info deviceID="` + deviceID + `"><name>Test Device</name></info>`
os.WriteFile(filepath.Join(devDir, "DeviceInfo.xml"), []byte(devInfo), 0644)
// Create Sources.xml with some empty displayNames (as provided in the issue)
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
<sources>
<source displayName="AUX IN" id="" secret="" secretType="">
<sourceKey type="AUX" account="AUX"></sourceKey>
</source>
<source displayName="" id="" secret="" secretType="token">
<sourceKey type="INTERNET_RADIO" account=""></sourceKey>
</source>
<source displayName="" id="" secret="S1" secretType="token">
<sourceKey type="LOCAL_INTERNET_RADIO" account=""></sourceKey>
</source>
<source displayName="test-user+spotify@gmail.com" id="" secret="S2" secretType="token">
<sourceKey type="SPOTIFY" account="test-user"></sourceKey>
</source>
<source displayName="" id="" secret="S3" secretType="token">
<sourceKey type="TUNEIN" account=""></sourceKey>
</source>
</sources>`
os.WriteFile(filepath.Join(devDir, "Sources.xml"), []byte(sourcesXML), 0644)
ds := datastore.NewDataStore(tempBaseDir)
sources, err := ds.GetConfiguredSources(accountID, deviceID)
if err != nil {
t.Fatalf("Failed to get configured sources: %v", err)
}
if len(sources) != 5 {
t.Errorf("Expected 5 sources, got %d", len(sources))
}
for i, s := range sources {
t.Logf("Source %d: ID=%s, DisplayName=%s, Type=%s, SourceKeyType=%s, Account=%s", i, s.ID, s.DisplayName, s.Type, s.SourceKeyType, s.SourceKey.Account)
if s.SourceKeyType == "" {
t.Errorf("Source %d (%s) has empty SourceKeyType", i, s.DisplayName)
}
if s.Type == "SPOTIFY" && s.SourceKey.Account != "test-user" {
t.Errorf("Source %d (%s) expected account 'test-user', got '%s'", i, s.DisplayName, s.SourceKey.Account)
}
label := constants.GetSourceLabel(s.Type)
t.Logf(" Label: %s", label)
if label == "" && s.Type != "" {
t.Errorf("Source %d (%s) has empty label for type %s", i, s.DisplayName, s.Type)
}
}
}
func TestReproduceMissingName(t *testing.T) {
tempBaseDir := "repro_data"
err := os.MkdirAll(tempBaseDir, 0755)
@@ -18,11 +90,11 @@ func TestReproduceMissingName(t *testing.T) {
}
defer os.RemoveAll(tempBaseDir)
accountID := "3230304"
accountID := "1234567"
// Create device folders
// 08DF1F0BA325 (has name)
// A81B6A536A98 (missing name in full_local.xml)
// 001122334455 (missing name in full_local.xml)
// Device 1: 08DF1F0BA325
dev1Dir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", "08DF1F0BA325")
@@ -48,14 +120,14 @@ func TestReproduceMissingName(t *testing.T) {
</info>`
os.WriteFile(filepath.Join(dev1Dir, "DeviceInfo.xml"), []byte(dev1Info), 0644)
// Device 2: A81B6A536A98 - MAC address ID in XML, name with special char or space?
dev2Dir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", "A81B6A536A98")
// Device 2: 001122334455 - MAC address ID in XML, name with special char or space?
dev2Dir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", "001122334455")
err = os.MkdirAll(dev2Dir, 0755)
if err != nil {
t.Fatal(err)
}
dev2Info := `<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="A81B6A536A98">
<info deviceID="001122334455">
<name>Sound Machinechen</name>
<type>SoundTouch</type>
<moduleType>10 sm2</moduleType>
@@ -72,7 +144,7 @@ func TestReproduceMissingName(t *testing.T) {
</components>
<networkInfo type="SCM">
<ipAddress>192.168.178.35</ipAddress>
<macAddress>A81B6A536A98</macAddress>
<macAddress>001122334455</macAddress>
</networkInfo>
<discoveryMethod>sync_full</discoveryMethod>
</info>`
@@ -103,28 +175,28 @@ func TestReproduceMissingName(t *testing.T) {
}
// Now test name preservation during sync
// Mock a response with empty name for A81B6A536A98
// Mock a response with empty name for 001122334455
for i := range resp.Devices {
if resp.Devices[i].DeviceID == "A81B6A536A98" {
if resp.Devices[i].DeviceID == "001122334455" {
resp.Devices[i].Name = ""
}
}
// Remove the account-specific device directory to force resolution to 'default'
os.RemoveAll(filepath.Join(tempBaseDir, "accounts", accountID, "devices", "A81B6A536A98"))
os.RemoveAll(filepath.Join(tempBaseDir, "accounts", accountID, "devices", "001122334455"))
// Create a duplicate directory in another place (e.g. 'st-go/data/accounts/default') with the CORRECT name
// This simulates a global entry that ds.ListAllDevices() should find
globalDevDir := filepath.Join("st-go", "data", "accounts", "default", "devices", "A81B6A536A98")
globalDevDir := filepath.Join("st-go", "data", "accounts", "default", "devices", "001122334455")
os.MkdirAll(globalDevDir, 0755)
defer os.RemoveAll("st-go")
globalDevInfo := `<info deviceID="A81B6A536A98"><name>Sound Machinechen</name><type>SoundTouch</type><moduleType>10 sm2</moduleType></info>`
globalDevInfo := `<info deviceID="001122334455"><name>Sound Machinechen</name><type>SoundTouch</type><moduleType>10 sm2</moduleType></info>`
os.WriteFile(filepath.Join(globalDevDir, "DeviceInfo.xml"), []byte(globalDevInfo), 0644)
// Create a directory in 'default' with EMPTY name (the one that GetDeviceInfo will pick up)
defaultDevDir := filepath.Join(tempBaseDir, "default", "devices", "A81B6A536A98")
defaultDevDir := filepath.Join(tempBaseDir, "default", "devices", "001122334455")
os.MkdirAll(defaultDevDir, 0755)
defaultDevInfo := `<info deviceID="A81B6A536A98"><name></name><type>SoundTouch</type><moduleType>10 sm2</moduleType></info>`
defaultDevInfo := `<info deviceID="001122334455"><name></name><type>SoundTouch</type><moduleType>10 sm2</moduleType></info>`
os.WriteFile(filepath.Join(defaultDevDir, "DeviceInfo.xml"), []byte(defaultDevInfo), 0644)
err = SyncFromAccountFull(ds, &resp)
@@ -133,7 +205,7 @@ func TestReproduceMissingName(t *testing.T) {
}
// Verify name was preserved
info, err := ds.GetDeviceInfo(accountID, "A81B6A536A98")
info, err := ds.GetDeviceInfo(accountID, "001122334455")
if err != nil {
t.Fatal(err)
}
@@ -163,7 +235,7 @@ func TestReproduceMissingName(t *testing.T) {
t.Error("Device 08DF1F0BA325 name should not be empty")
}
}
if d.DeviceID == "A81B6A536A98" || d.DeviceID == "I6332527703739342000020" {
if d.DeviceID == "001122334455" || d.DeviceID == "I6332527703739342000020" {
if d.Name != "" {
foundA8 = true
}
@@ -174,6 +246,297 @@ func TestReproduceMissingName(t *testing.T) {
t.Error("Device 08DF1F0BA325 not found in response")
}
if !foundA8 {
t.Error("Device A81B6A536A98 not found in response")
t.Error("Device 001122334455 not found in response")
}
}
func TestRecentItemsMissingSources(t *testing.T) {
tempBaseDir := "repro_recents_data"
err := os.MkdirAll(tempBaseDir, 0755)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempBaseDir)
accountID := "1234567"
deviceID := "001122334455"
// Create device directory
devDir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", deviceID)
err = os.MkdirAll(devDir, 0755)
if err != nil {
t.Fatal(err)
}
// Create Recents.xml with some entries that have missing sources or sparse data
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
<recents>
<recent id="1" deviceID="001122334455" utcTime="1771916458">
<contentItem source="INTERNET_RADIO" type="tracklisturl" location="/some/loc" isPresetable="true">
<itemName>For Your Darkest Days</itemName>
</contentItem>
</recent>
<recent id="2" deviceID="001122334455" utcTime="1771916459">
<contentItem source="SPOTIFY" type="tracklisturl" location="/spotify/loc" sourceAccount="test-user" isPresetable="true">
<itemName>Spotify Item</itemName>
</contentItem>
</recent>
</recents>`
os.WriteFile(filepath.Join(devDir, "Recents.xml"), []byte(recentsXML), 0644)
// Create Sources.xml with matching sources
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
<sources>
<source displayName="" id="ir_source" type="INTERNET_RADIO">
<sourceKey type="INTERNET_RADIO" account=""></sourceKey>
</source>
<source displayName="test-user+spotify@gmail.com" id="spotify_source" type="SPOTIFY">
<sourceKey type="SPOTIFY" account="test-user"></sourceKey>
</source>
</sources>`
os.WriteFile(filepath.Join(devDir, "Sources.xml"), []byte(sourcesXML), 0644)
ds := datastore.NewDataStore(tempBaseDir)
recents, err := ds.GetRecents(accountID, deviceID)
if err != nil {
t.Fatalf("Failed to get recents: %v", err)
}
if len(recents) != 2 {
t.Errorf("Expected 2 recents, got %d", len(recents))
}
for _, r := range recents {
t.Logf("Recent: ID=%s, Name=%s, Source=%s, SourceAccount=%s", r.ID, r.Name, r.Source, r.SourceAccount)
if r.Name == "" {
t.Errorf("Recent %s has empty Name", r.ID)
}
if r.Source == "" {
t.Errorf("Recent %s has empty Source attribute", r.ID)
}
}
}
func TestSyncSourcesAttributes(t *testing.T) {
tempBaseDir := "sync_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)
}
xmlData := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<account id="1234567">
<devices>
<device deviceid="08DF1F0BA325">
<presets>
<preset buttonNumber="1">
<name>test-playlist</name>
<source id="10863533" type="Audio">
<name>test-user</name>
<sourceproviderid>15</sourceproviderid>
<sourcename>test-user+spotify@gmail.com</sourcename>
<username>test-user</username>
</source>
</preset>
</presets>
<recents>
<recent id="2538285498">
<contentItem source="SPOTIFY" type="tracklisturl" location="spotify:track:abc" sourceAccount="test-user" isPresetable="true">
<itemName>test-playlist</itemName>
</contentItem>
<source id="10863533" type="Audio">
<name>test-user</name>
<sourceproviderid>15</sourceproviderid>
<sourcename>test-user+spotify@gmail.com</sourcename>
<username>test-user</username>
</source>
</recent>
</recents>
</device>
</devices>
<sources>
<source id="10863533" type="Audio">
<name>test-user</name>
<sourceproviderid>15</sourceproviderid>
<sourcename>test-user+spotify@gmail.com</sourcename>
<username>test-user</username>
</source>
</sources>
</account>`
var resp models.AccountFullResponse
err = xml.Unmarshal([]byte(xmlData), &resp)
if err != nil {
t.Fatal(err)
}
// Verify unmarshaling of attributes
if resp.ID != "1234567" {
t.Errorf("Expected account ID 1234567, got %s", resp.ID)
}
if len(resp.Devices) == 0 {
t.Fatal("No devices found in unmarshaled response")
}
dev := resp.Devices[0]
if len(dev.Presets) == 0 {
t.Fatal("No presets found in unmarshaled response")
}
p := dev.Presets[0]
if p.Source.ID != "10863533" {
t.Errorf("Preset source ID not unmarshaled: expected 10863533, got '%s'", p.Source.ID)
}
if p.Source.Type != "Audio" {
t.Errorf("Preset source Type not unmarshaled: expected Audio, got '%s'", p.Source.Type)
}
err = SyncFromAccountFull(ds, &resp)
if err != nil {
t.Fatal(err)
}
// Check datastore
presets, err := ds.GetPresets("1234567", "08DF1F0BA325")
if err != nil {
t.Fatal(err)
}
if len(presets) == 0 {
t.Fatal("No presets found in datastore after sync")
}
lp := presets[0]
if lp.SourceConfig == nil {
t.Fatal("SourceConfig is nil for synced preset")
}
if lp.SourceConfig.ID != "10863533" {
t.Errorf("Synced preset source ID mismatch: expected 10863533, got '%s'", lp.SourceConfig.ID)
}
if lp.SourceConfig.Type != "Audio" {
t.Errorf("Synced preset source Type mismatch: expected Audio, got '%s'", lp.SourceConfig.Type)
}
recents, err := ds.GetRecents("1234567", "08DF1F0BA325")
if err != nil {
t.Fatal(err)
}
if len(recents) == 0 {
t.Fatal("No recents found in datastore after sync")
}
lr := recents[0]
if lr.SourceConfig == nil {
t.Fatal("SourceConfig is nil for synced recent")
}
if lr.SourceConfig.ID != "10863533" {
t.Errorf("Synced recent source ID mismatch: expected 10863533, got '%s'", lr.SourceConfig.ID)
}
if lr.SourceConfig.Type != "Audio" {
t.Errorf("Synced recent source Type mismatch: expected Audio, got '%s'", lr.SourceConfig.Type)
}
}
func TestSyncSourcesAggregation(t *testing.T) {
tempBaseDir := "sync_agg_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)
}
xmlData := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<account id="agg_account">
<devices>
<device deviceid="dev1">
<presets>
<preset buttonNumber="1">
<name>Preset Source</name>
<source id="src_preset" type="Audio">
<name>Preset Source Name</name>
</source>
</preset>
</presets>
<recents>
<recent id="rec1">
<contentItem source="TUNEIN" type="stationurl" location="tunein:station:s123" sourceAccount="" isPresetable="true">
<itemName>Recent Source</itemName>
</contentItem>
<source id="src_recent" type="Audio">
<name>Recent Source Name</name>
</source>
</recent>
</recents>
</device>
</devices>
<sources>
<source id="src_account" type="Audio">
<name>Account Source Name</name>
</source>
</sources>
</account>`
var resp models.AccountFullResponse
err = xml.Unmarshal([]byte(xmlData), &resp)
if err != nil {
t.Fatal(err)
}
err = SyncFromAccountFull(ds, &resp)
if err != nil {
t.Fatal(err)
}
// Check aggregated sources for dev1
sources, err := ds.GetConfiguredSources("agg_account", "dev1")
if err != nil {
t.Fatal(err)
}
// We expect 3 sources: src_account, src_preset, src_recent
if len(sources) != 3 {
t.Errorf("Expected 3 aggregated sources, got %d", len(sources))
for _, s := range sources {
t.Logf("Found source: ID=%s, Name=%s", s.ID, s.Name)
}
}
foundAccount := false
foundPreset := false
foundRecent := false
for _, s := range sources {
switch s.ID {
case "src_account":
foundAccount = true
case "src_preset":
foundPreset = true
case "src_recent":
foundRecent = true
}
}
if !foundAccount {
t.Error("Source 'src_account' not found in aggregated sources")
}
if !foundPreset {
t.Error("Source 'src_preset' not found in aggregated sources")
}
if !foundRecent {
t.Error("Source 'src_recent' not found in aggregated sources")
}
}
+70 -3
View File
@@ -17,6 +17,8 @@ func SyncFromAccountFull(ds *datastore.DataStore, resp *models.AccountFullRespon
}
log.Printf("[SYNC] Starting synchronization for account %s", accountID)
// 0. Update Account Metadata
syncAccountInfo(ds, accountID, resp)
for i := range resp.Devices {
dev := &resp.Devices[i]
@@ -32,7 +34,7 @@ func SyncFromAccountFull(ds *datastore.DataStore, resp *models.AccountFullRespon
syncDeviceInfo(ds, accountID, dev)
// 2. Update Configured Sources for this device
syncConfiguredSources(ds, accountID, deviceID, resp.Sources)
syncConfiguredSources(ds, accountID, deviceID, resp.Sources, dev)
// 3. Update Presets
syncPresets(ds, accountID, deviceID, dev.Presets)
@@ -46,6 +48,18 @@ func SyncFromAccountFull(ds *datastore.DataStore, resp *models.AccountFullRespon
return nil
}
func syncAccountInfo(ds *datastore.DataStore, accountID string, resp *models.AccountFullResponse) {
info := &models.ServiceAccountInfo{
AccountID: accountID,
PreferredLanguage: resp.PreferredLanguage,
ProviderSettings: resp.ProviderSettings,
}
if err := ds.SaveAccountInfo(accountID, info); err != nil {
log.Printf("[SYNC_ERR] Failed to save account info for %s: %v", accountID, err)
}
}
func syncDeviceInfo(ds *datastore.DataStore, accountID string, dev *models.AccountDevice) {
deviceID := dev.DeviceID
existingInfo, _ := ds.GetDeviceInfo(accountID, deviceID)
@@ -61,7 +75,15 @@ func syncDeviceInfo(ds *datastore.DataStore, accountID string, dev *models.Accou
}
if dev.AttachedProduct != nil {
info.ProductCode = dev.AttachedProduct.ProductCode
info.ProductSerialNumber = dev.AttachedProduct.SerialNumber
for _, comp := range dev.AttachedProduct.Components {
info.Components = append(info.Components, models.ServiceComponent{
Category: comp.Category,
SoftwareVersion: comp.SoftwareVersion,
SerialNumber: comp.SerialNumber,
})
}
}
// If the name is empty in the upstream response, try to preserve the local name
@@ -102,14 +124,46 @@ func syncDeviceInfo(ds *datastore.DataStore, accountID string, dev *models.Accou
}
}
func syncConfiguredSources(ds *datastore.DataStore, accountID, deviceID string, sources []models.FullResponseSource) {
func syncConfiguredSources(ds *datastore.DataStore, accountID, deviceID string, sources []models.FullResponseSource, dev *models.AccountDevice) {
// We'll use the account-level sources from the response as a base.
var deviceSources []models.ConfiguredSource
// Track seen sources to avoid duplicates
seen := make(map[string]bool)
// 1. Add sources from the account-level sources list
for i := range sources {
s := &sources[i]
if s.ID != "" && seen[s.ID] {
continue
}
dsrc := mapFullSourceToConfiguredSource(*s)
deviceSources = append(deviceSources, dsrc)
if s.ID != "" {
seen[s.ID] = true
}
}
// 2. Add sources from presets if they are not already in the list
for i := range dev.Presets {
p := &dev.Presets[i]
if p.Source.ID != "" && !seen[p.Source.ID] {
dsrc := mapFullSourceToConfiguredSource(p.Source)
deviceSources = append(deviceSources, dsrc)
seen[p.Source.ID] = true
}
}
// 3. Add sources from recents if they are not already in the list
for i := range dev.Recents {
r := &dev.Recents[i]
if r.Source.ID != "" && !seen[r.Source.ID] {
dsrc := mapFullSourceToConfiguredSource(r.Source)
deviceSources = append(deviceSources, dsrc)
seen[r.Source.ID] = true
}
}
if err := ds.SaveConfiguredSources(accountID, deviceID, deviceSources); err != nil {
@@ -132,6 +186,7 @@ func syncPresets(ds *datastore.DataStore, accountID, deviceID string, presetsSou
SourceAccount: p.Source.Username,
},
ButtonNumber: p.ButtonNumber,
ID: p.ButtonNumber,
CreatedOn: p.CreatedOn,
UpdatedOn: p.UpdatedOn,
ContainerArt: p.ContainerArt,
@@ -142,6 +197,7 @@ func syncPresets(ds *datastore.DataStore, accountID, deviceID string, presetsSou
UpdatedOn: p.Source.UpdatedOn,
SourceName: p.Source.SourceName,
DisplayName: p.Source.Name,
Name: p.Source.Name,
SourceProviderID: p.Source.SourceProviderID,
Secret: p.Source.Credential.Value,
SecretType: p.Source.Credential.Type,
@@ -181,6 +237,7 @@ func syncRecents(ds *datastore.DataStore, accountID, deviceID string, recentsSou
UpdatedOn: r.Source.UpdatedOn,
SourceName: r.Source.SourceName,
DisplayName: r.Source.Name,
Name: r.Source.Name,
SourceProviderID: r.Source.SourceProviderID,
Secret: r.Source.Credential.Value,
SecretType: r.Source.Credential.Type,
@@ -202,13 +259,23 @@ func mapFullSourceToConfiguredSource(s models.FullResponseSource) models.Configu
CreatedOn: s.CreatedOn,
UpdatedOn: s.UpdatedOn,
SourceName: s.SourceName,
DisplayName: s.Name,
DisplayName: s.DisplayName,
Name: s.Name,
SourceProviderID: s.SourceProviderID,
Secret: s.Credential.Value,
SecretType: s.Credential.Type,
Username: s.Username,
SourceSettings: s.SourceSettings,
}
if dsrc.DisplayName == "" {
dsrc.DisplayName = s.Name
}
if dsrc.Name == "" {
dsrc.Name = s.DisplayName
}
dsrc.SourceKey.Type = s.Type
dsrc.SourceKey.Account = s.Username
+7 -2
View File
@@ -117,7 +117,12 @@ func TestSyncFromAccountFull(t *testing.T) {
if err != nil {
t.Errorf("Failed to get sources: %v", err)
}
if len(sources) != 1 {
t.Errorf("Expected 1 source, got %d", len(sources))
// Now we aggregate sources from Account + Preset + Recent.
// Account has TUNEIN.
// Preset has TUNEIN (same ID, so deduplicated).
// Recent has SPOTIFY (new ID, so added).
// Total expected: 2
if len(sources) != 2 {
t.Errorf("Expected 2 sources (aggregated), got %d", len(sources))
}
}
+19 -2
View File
@@ -73,6 +73,8 @@ type MigrationSummary struct {
IsMigrated bool `json:"is_migrated"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
PreferredSource string `json:"preferred_source,omitempty"`
}
// SSHClient defines the interface for SSH operations.
@@ -323,6 +325,8 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
if err == nil {
summary.MirrorEnabled = settings.MirrorEnabled
summary.MirrorEndpoints = settings.MirrorEndpoints
summary.SkipMirrorEndpoints = settings.SkipMirrorEndpoints
summary.PreferredSource = settings.PreferredSource
}
}
@@ -2119,8 +2123,11 @@ func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
presetsURL = fmt.Sprintf("http://%s/presets", deviceIP)
}
log.Printf("[SYNC] Syncing presets for %s", deviceIP)
resp, err := m.HTTPGet(presetsURL)
if err != nil {
log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", deviceIP, err)
return
}
@@ -2159,6 +2166,8 @@ func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
SourceID: "", // Preset doesn't have SourceID in ContentItem usually
IsPresetable: strconv.FormatBool(p.ContentItem.IsPresetable),
},
ID: strconv.Itoa(p.ID),
ButtonNumber: strconv.Itoa(p.ID),
ContainerArt: p.ContentItem.ContainerArt,
CreatedOn: createdOn,
UpdatedOn: updatedOn,
@@ -2255,9 +2264,17 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
for _, s := range srs.SourceItem {
cs := models.ConfiguredSource{
DisplayName: s.DisplayName,
ID: s.Source,
SecretType: string(s.Status),
Secret: "",
SecretType: "",
}
if s.Status == "READY" {
cs.SecretType = "token"
}
if s.Source == "SPOTIFY" {
cs.SecretType = "token_version_3"
}
cs.SourceKey.Type = s.Source
cs.SourceKey.Account = s.SourceAccount
// Also set legacy fields for now
+304
View File
@@ -0,0 +1,304 @@
package setup
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
func TestSyncPresets_PreservesID(t *testing.T) {
// 1. Setup mock SoundTouch device (HTTP server)
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/presets":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8" ?>
<presets>
<preset id="1" createdOn="1719128436" updatedOn="1728740382">
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh" sourceAccount="test-user" isPresetable="true">
<itemName>test-playlist</itemName>
<containerArt>https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b</containerArt>
</ContentItem>
</preset>
<preset id="6" createdOn="1585502139" updatedOn="1769977469">
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s213886" isPresetable="true">
<itemName>WDR 2 Rheinland</itemName>
<containerArt>https://cdn-radiotime-logos.tunein.com/s213886g.png</containerArt>
</ContentItem>
</preset>
</presets>`)
case "/info":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<nowPlaying deviceID="001122334455" source="STANDBY" />`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockDevice.Close()
// 2. Setup DataStore
tempDir, err := os.MkdirTemp("", "st-sync-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
// 3. Setup Manager
m := NewManager("http://localhost:8080", ds, nil)
// Since HTTPGet is private, we'll rely on NewManager setting it to http.Get,
// which will work fine with our httptest server.
// 4. Run Sync
deviceIP := mockDevice.Listener.Addr().String()
accountID := "1234567"
deviceID := "001122334455"
m.syncPresets(deviceIP, accountID, deviceID)
// 5. Verify the saved file
presetFile := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Presets.xml")
data, err := os.ReadFile(presetFile)
if err != nil {
t.Fatalf("Failed to read saved presets file: %v", err)
}
content := string(data)
if !strings.Contains(content, `id="1"`) {
t.Errorf("Saved XML missing id=\"1\":\n%s", content)
}
if !strings.Contains(content, `id="6"`) {
t.Errorf("Saved XML missing id=\"6\":\n%s", content)
}
}
func TestSyncPresets_PreservesEmptySourceAccount(t *testing.T) {
// 1. Setup mock SoundTouch device (HTTP server)
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/presets" {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8" ?>
<presets>
<preset id="6" createdOn="1585502139" updatedOn="1769977469">
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s213886" sourceAccount="" isPresetable="true">
<itemName>WDR 2 Rheinland</itemName>
<containerArt>https://cdn-radiotime-logos.tunein.com/s213886g.png</containerArt>
</ContentItem>
</preset>
</presets>`)
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockDevice.Close()
// 2. Setup DataStore
tempDir, err := os.MkdirTemp("", "st-sync-test-sourceaccount-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
// 3. Setup Manager
m := NewManager("http://localhost:8080", ds, nil)
// 4. Run Sync
deviceIP := mockDevice.Listener.Addr().String()
accountID := "1234567"
deviceID := "001122334455"
m.syncPresets(deviceIP, accountID, deviceID)
// 5. Verify the saved file
presetFile := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Presets.xml")
data, err := os.ReadFile(presetFile)
if err != nil {
t.Fatalf("Failed to read saved presets file: %v", err)
}
content := string(data)
if !strings.Contains(content, `sourceAccount=""`) {
t.Errorf("Saved XML missing sourceAccount=\"\":\n%s", content)
}
}
func TestSyncRecents_PreservesID(t *testing.T) {
// 1. Setup mock SoundTouch device (HTTP server)
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/recents":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8" ?>
<recents>
<recent deviceID="001122334455" utcTime="1719128436" id="101">
<contentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh" sourceAccount="test-user" isPresetable="true">
<itemName>test-playlist</itemName>
<containerArt>https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b</containerArt>
</contentItem>
</recent>
</recents>`)
case "/info":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<nowPlaying deviceID="001122334455" source="STANDBY" />`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockDevice.Close()
// 2. Setup DataStore
tempDir, err := os.MkdirTemp("", "st-sync-recents-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
// 3. Setup Manager
m := NewManager("http://localhost:8080", ds, nil)
// 4. Run Sync
deviceIP := mockDevice.Listener.Addr().String()
accountID := "1234567"
deviceID := "001122334455"
m.syncRecents(deviceIP, accountID, deviceID)
// 5. Verify the saved file
recentFile := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Recents.xml")
data, err := os.ReadFile(recentFile)
if err != nil {
t.Fatalf("Failed to read saved recents file: %v", err)
}
content := string(data)
if !strings.Contains(content, `id="101"`) {
t.Errorf("Saved XML missing id=\"101\":\n%s", content)
}
}
func TestSyncRecents_PreservesEmptySourceAccount(t *testing.T) {
// 1. Setup mock SoundTouch device (HTTP server)
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/recents" {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8" ?>
<recents>
<recent deviceID="001122334455" utcTime="1719128436" id="101">
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s213886" sourceAccount="" isPresetable="true">
<itemName>WDR 2 Rheinland</itemName>
<containerArt>https://cdn-radiotime-logos.tunein.com/s213886g.png</containerArt>
</contentItem>
</recent>
</recents>`)
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockDevice.Close()
// 2. Setup DataStore
tempDir, err := os.MkdirTemp("", "st-sync-recents-test-sourceaccount-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
// 3. Setup Manager
m := NewManager("http://localhost:8080", ds, nil)
// 4. Run Sync
deviceIP := mockDevice.Listener.Addr().String()
accountID := "1234567"
deviceID := "001122334455"
m.syncRecents(deviceIP, accountID, deviceID)
// 5. Verify the saved file
recentFile := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Recents.xml")
data, err := os.ReadFile(recentFile)
if err != nil {
t.Fatalf("Failed to read saved recents file: %v", err)
}
content := string(data)
if !strings.Contains(content, `sourceAccount=""`) {
t.Errorf("Saved XML missing sourceAccount=\"\":\n%s", content)
}
}
func TestSyncSources_Format(t *testing.T) {
// 1. Setup mock SoundTouch device (HTTP server)
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/info":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8" ?>
<info deviceID="001122334455">
<name>Test Device</name>
<type>SoundTouch 10</type>
<margeAccountUUID>1234567</margeAccountUUID>
</info>`)
case "/sources":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8" ?>
<sources deviceID="001122334455">
<sourceItem source="AUX" status="READY" isLocal="true" multiroomallowed="true">AUX IN</sourceItem>
<sourceItem source="SPOTIFY" sourceAccount="test-user" status="READY" isLocal="false" multiroomallowed="true">test-user</sourceItem>
</sources>`)
case "/presets", "/recents":
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<root></root>`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockDevice.Close()
// 2. Setup DataStore
tempDir, err := os.MkdirTemp("", "st-sync-sources-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
// 3. Setup Manager
m := NewManager("http://localhost:8080", ds, nil)
// 4. Run Sync
deviceIP := mockDevice.Listener.Addr().String()
accountID := "1234567"
deviceID := "001122334455"
err = m.SyncDeviceData(deviceIP)
if err != nil {
t.Fatalf("SyncDeviceData failed: %v", err)
}
// 5. Verify the saved file
sourceFile := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Sources.xml")
data, err := os.ReadFile(sourceFile)
if err != nil {
t.Fatalf("Failed to read saved sources file at %s: %v", sourceFile, err)
}
content := string(data)
if !strings.Contains(content, `<source displayName="AUX IN" secret="" secretType="token">`) {
t.Errorf("Saved XML missing or incorrect AUX source:\n%s", content)
}
if !strings.Contains(content, `<source displayName="test-user" secret="" secretType="token_version_3">`) {
t.Errorf("Saved XML missing or incorrect Spotify source:\n%s", content)
}
if strings.Contains(content, "<sourcename>") {
t.Errorf("Saved XML contains legacy tags:\n%s", content)
}
}
@@ -0,0 +1,45 @@
### Create Account (Official Stockholm endpoint)
POST {{host}}/streaming/account
Content-Type: application/vnd.bose.customer-v1.0+xml
<?xml version="1.0" encoding="UTF-8"?>
<account id="{{accountId}}">
<firstName>John</firstName>
<lastName>Doe</lastName>
<email>john.doe@example.com</email>
<password>password123</password>
<countryCode>US</countryCode>
<preferredLanguage>en</preferredLanguage>
</account>
> {%
client.test("Account created via XML successfully", function() {
client.assert(response.status === 201, "Response status is not 201");
const doc = response.body;
const account = doc.getElementsByTagName("account")[0];
client.assert(account !== undefined, "Response body should contain account XML");
var accountId = account.getAttribute("id");
if (client.variables.environment.get("accountId")) {
client.assert(accountId === client.variables.environment.get("accountId"), "Account ID "+accountId+" should match environment variable if provided");
}
client.global.set("newAccountId", accountId);
});
%}
### Login (Official Stockholm endpoint)
POST {{host}}/streaming/account/login
Content-Type: application/vnd.bose.streaming-v1.2+xml
<?xml version="1.0" encoding="UTF-8"?>
<login>
<username>john.doe@example.com</username>
<password>password123</password>
</login>
> {%
client.test("Login successful", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.headers.valueOf("Credentials") !== null, "Credentials header missing");
client.global.set("authToken", response.headers.valueOf("Credentials"));
});
%}
@@ -0,0 +1,22 @@
### GET /streaming/account/{{newAccountId}}/full
GET {{host}}/streaming/account/{{newAccountId}}/full
Host: streaming.bose.com
Authorization: Bearer {{authToken}}
Content-Type: application/vnd.bose.streaming-v1.2+xml
User-Agent: Bose_Lisa/27.0.6
Accept: application/vnd.bose.streaming-v1.2+xml
> {%
client.test("Request executed successfully", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.contentType.mimeType === "application/vnd.bose.streaming-v1.2+xml", "Expected 'application/vnd.bose.streaming-v1.2+xml' but received '" + response.contentType.mimeType + "'");
});
client.test("Response body contains <account>", function() {
const expectedAccountId = client.global.get("newAccountId") || client.variables.environment.get("accountId");
const doc = response.body;
const account = doc.getElementsByTagName("account")[0];
client.assert(account !== undefined, "Response body does not contain <account>");
client.assert(account.getAttribute("id") === expectedAccountId, "Expected account id '" + expectedAccountId + "' but received '" + account.getAttribute("id") + "'");
});
%}
@@ -0,0 +1,19 @@
### GET /streaming/account/{{newAccountId}}/device/{{deviceId}}/group/
GET {{host}}/streaming/account/{{newAccountId}}/device/{{deviceId}}/group/
Host: streaming.bose.com
Content-Type: application/vnd.bose.streaming-v1.2+xml
User-Agent: Bose_Lisa/27.0.6
Accept: application/vnd.bose.streaming-v1.2+xml
Authorization: Bearer {{authToken}}
> {%
client.test("Request executed successfully", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.contentType.mimeType === "application/vnd.bose.streaming-v1.2+xml", "Expected 'application/vnd.bose.streaming-v1.2+xml' but received '" + response.contentType.mimeType + "'");
});
client.test("Response body contains <group/>", function() {
const doc = response.body;
client.assert(doc.getElementsByTagName("group").length > 0, "Response body does not contain <group/>");
});
%}
@@ -0,0 +1,19 @@
### GET /streaming/account/{{newAccountId}}/provider_settings
GET {{host}}/streaming/account/{{newAccountId}}/provider_settings
Host: streaming.bose.com
Accept: application/vnd.bose.streaming-v1.2+xml
Authorization: Bearer {{authToken}}
Content-Type: application/vnd.bose.streaming-v1.2+xml
User-Agent: Bose_Lisa/27.0.6
> {%
client.test("Request executed successfully", function() {
client.assert(response.status === 200, "Response status is not 200");
client.assert(response.contentType.mimeType === "application/vnd.bose.streaming-v1.2+xml", "Expected 'application/vnd.bose.streaming-v1.2+xml' but received '" + response.contentType.mimeType + "'");
});
client.test("Response body contains <providerSettings>", function() {
const doc = response.body;
client.assert(doc.getElementsByTagName("providerSettings").length > 0, "Response body does not contain <providerSettings>");
});
%}
@@ -0,0 +1,30 @@
{
"local": {
"host": "http://localhost:8000",
"token": "example-token",
"deviceId": "B05ECAFE",
"serialNumber": "K12345",
"productCode": "SoundTouch test",
"productSerialNumber": "237983",
"gatewayIp": "192.168.1.1",
"deviceIp": "192.168.1.100",
"macAddress1": "B05ECAFE",
"macAddress2": "B05ECAFF",
"accountId": "7654321",
"deviceName": "SoundTouch-20"
},
"ci": {
"host": "http://soundtouch-service:8000",
"token": "example-token",
"deviceId": "B05ECAFE",
"serialNumber": "K12345",
"productCode": "SoundTouch test",
"productSerialNumber": "237983",
"gatewayIp": "192.168.1.1",
"deviceIp": "192.168.1.100",
"macAddress1": "B05ECAFE",
"macAddress2": "B05ECAFF",
"accountId": "7654321",
"deviceName": "SoundTouch-20"
}
}
@@ -0,0 +1,15 @@
### POST /streaming/support/power_on
POST {{host}}/streaming/support/power_on
Host: streaming.bose.com
User-Agent: Bose_Lisa/27.0.6
Accept: application/vnd.bose.streaming-v1.2+xml
Authorization: Bearer {{authToken}}
Content-Type: application/vnd.bose.streaming-v1.2+xml
<?xml version="1.0" encoding="UTF-8" ?><device-data><device id="{{deviceId}}"><serialnumber>{{serialNumber}}</serialnumber><firmware-version>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</firmware-version><product product_code="{{productCode}}" type="5"><serialnumber>{{productSerialNumber}}</serialnumber></product></device><diagnostic-data><device-landscape><rssi>Excellent</rssi><gateway-ip-address>{{gatewayIp}}</gateway-ip-address><macaddresses><macaddress>{{macAddress1}}</macaddress><macaddress>{{macAddress2}}</macaddress></macaddresses><ip-address>{{deviceIp}}</ip-address><network-connection-type>Wireless</network-connection-type></device-landscape><network-landscape><network-data xmlns="http://www.Bose.com/Schemas/2012-12/NetworkMonitor/" /></network-landscape></diagnostic-data></device-data>
> {%
client.test("Request executed successfully", function() {
client.assert(response.status === 200, "Response status is not 200");
});
%}
@@ -0,0 +1,19 @@
### POST /{{newAccountId}}/devices (Register Device)
POST {{host}}/accounts/{{newAccountId}}/devices
Content-Type: application/vnd.bose.streaming-v1.2+xml
Authorization: Bearer {{authToken}}
<?xml version="1.0" encoding="UTF-8" ?>
<device deviceid="{{deviceId}}">
<name>{{deviceName}}</name>
</device>
> {%
client.test("Device registered successfully", function() {
client.assert(response.status === 200 || response.status === 201, "Response status is not 200 or 201");
const doc = response.body;
const device = doc.getElementsByTagName("device")[0];
client.assert(device !== undefined, "Response body should contain <device>");
client.assert(device.getAttribute("deviceid") === client.variables.environment.get("deviceId"), "Response body should contain the deviceId");
});
%}
@@ -0,0 +1 @@
*.xml
@@ -0,0 +1,9 @@
### DELETE /{{newAccountId}}/devices/{{deviceId}} (Unregister Device)
DELETE {{host}}/accounts/{{newAccountId}}/devices/{{deviceId}}
Authorization: Bearer {{authToken}}
> {%
client.test("Device unregistered successfully", function() {
client.assert(response.status === 200, "Response status is not 200");
});
%}