feat/soulsync-support (#365)

This commit is contained in:
cl.st authored and GitHub committed 2026-10-07 18:34:02 +02:00
1 parent c679154bbf
commit 6347e7be49
17 files changed
+1582 -835

No files matched your search

+4
View File
@@ -13,6 +13,10 @@
/build
/dist
#docker tests
.docker
docker-compose.yml
# misc
.DS_Store
.env.local
+6
View File
@@ -3,6 +3,12 @@ Shazarr notable changes.
[Keep a Changelog](http://keepachangelog.com/en/1.0.0/) format.
## 📦 0.3.6
### 🚀 Added
* Add Soulsync support #358
### 🖍 Changed
* Move from Matomo to Umami
## 📦 0.3.5
### 🚀 Added
* Add new option to auto-shazam on app open #337
+5
View File
@@ -13,6 +13,7 @@ Shazarr project is a mobile app (android, ios) providing Shazam song recognition
- Audio microphone capture and song recognition using reverse Shazam API with [node-shazam-api](https://github.com/asivery/node-shazam-api).
- Download discovered album with [Lidarr](https://github.com/linuxserver/docker-lidarr), auto-search via API (with browser fallback if no API key)
- Download discovered track or album with [Tidarr](https://github.com/cstaelen/tidarr), search Tidal content in-app, pick the album, queue track or full album download, or with Tidarr 1.2.1 or higher, add track to Tidal favorites
- Add to [SoulSync](https://github.com/Nezreka/SoulSync) wishlist to trigger automatic download (via API)
- Add custom search service
- Listen on streaming app Spotify, Apple Music and Deezer
- Show lyrics
@@ -37,6 +38,9 @@ Get last release :
- **Lidarr URL** : `http://<lidarr-web-ui-url>`
- **Tidarr URL**: `http://<tidarr-web-ui-url>`
- **SoulSync URL**: `http://<soulsync-web-ui-url>:8008`
- **SoulSync API Key**: `your-api-key` (generated from SoulSync settings)
- **SoulSync Profile ID**: `1` (optional, defaults to 1)
- **Custom service URL**: `http://<service-url>?query=`
- **Custom service name**: `My custom music service`
@@ -94,4 +98,5 @@ If you like this project you can support here:
- UI inspiration : https://github.com/codrops/ShazamButtonEffect
- See Lidarr project: https://github.com/linuxserver/docker-lidarr
- See Tidarr project: https://github.com/cstaelen/tidarr
- See SoulSync project: https://github.com/Nezreka/SoulSync
+10 -10
View File
@@ -1,30 +1,30 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.5.0_@capacitor+core@8.5.0/node_modules/@capacitor/android/capacitor')
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.5.2_@capacitor+core@8.5.2/node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.1_@capacitor+core@8.5.0/node_modules/@capacitor/app/android')
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.1_@capacitor+core@8.5.2/node_modules/@capacitor/app/android')
include ':capacitor-device'
project(':capacitor-device').projectDir = new File('../node_modules/.pnpm/@capacitor+device@8.0.3_@capacitor+core@8.5.0/node_modules/@capacitor/device/android')
project(':capacitor-device').projectDir = new File('../node_modules/.pnpm/@capacitor+device@8.0.3_@capacitor+core@8.5.2/node_modules/@capacitor/device/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.3_@capacitor+core@8.5.0/node_modules/@capacitor/filesystem/android')
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.3_@capacitor+core@8.5.2/node_modules/@capacitor/filesystem/android')
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/.pnpm/@capacitor+haptics@8.0.2_@capacitor+core@8.5.0/node_modules/@capacitor/haptics/android')
project(':capacitor-haptics').projectDir = new File('../node_modules/.pnpm/@capacitor+haptics@8.0.2_@capacitor+core@8.5.2/node_modules/@capacitor/haptics/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+local-notifications@8.3.1_@capacitor+core@8.5.0/node_modules/@capacitor/local-notifications/android')
project(':capacitor-local-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+local-notifications@8.3.1_@capacitor+core@8.5.2/node_modules/@capacitor/local-notifications/android')
include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../node_modules/.pnpm/@capacitor+network@8.0.1_@capacitor+core@8.5.0/node_modules/@capacitor/network/android')
project(':capacitor-network').projectDir = new File('../node_modules/.pnpm/@capacitor+network@8.0.1_@capacitor+core@8.5.2/node_modules/@capacitor/network/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.5.0/node_modules/@capacitor/preferences/android')
project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.5.2/node_modules/@capacitor/preferences/android')
include ':capacitor-screen-orientation'
project(':capacitor-screen-orientation').projectDir = new File('../node_modules/.pnpm/@capacitor+screen-orientation@8.0.1_@capacitor+core@8.5.0/node_modules/@capacitor/screen-orientation/android')
project(':capacitor-screen-orientation').projectDir = new File('../node_modules/.pnpm/@capacitor+screen-orientation@8.0.1_@capacitor+core@8.5.2/node_modules/@capacitor/screen-orientation/android')
include ':capacitor-voice-recorder'
project(':capacitor-voice-recorder').projectDir = new File('../node_modules/.pnpm/capacitor-voice-recorder@7.0.6_@capacitor+core@8.5.0_@capacitor+filesystem@8.1.3_@capacitor+core@8.5.0_/node_modules/capacitor-voice-recorder/android')
project(':capacitor-voice-recorder').projectDir = new File('../node_modules/.pnpm/capacitor-voice-recorder@7.0.6_@capacitor+core@8.5.2_@capacitor+filesystem@8.1.3_@capacitor+core@8.5.2_/node_modules/capacitor-voice-recorder/android')
+578 -784
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -34,6 +34,21 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
placeholder: "Tidarr API key (optional, enables auto-search)",
type: "text",
},
soulsync_url: {
value: currentConfig.soulsync_url,
placeholder: "SoulSync URL (http://...)",
type: "url",
},
soulsync_api_key: {
value: currentConfig.soulsync_api_key,
placeholder: "SoulSync API key (Bearer token)",
type: "text",
},
soulsync_profile_id: {
value: currentConfig.soulsync_profile_id,
placeholder: "SoulSync Profile ID (optional, default: 1)",
type: "text",
},
custom_service_url: {
value: currentConfig.custom_service_url,
placeholder: "Custom service (http://...?query=)",
+3
View File
@@ -5,6 +5,9 @@ export type ConfigStoreType = {
lidarr_api_key: string;
tidarr_url: string;
tidarr_api_key: string;
soulsync_url: string;
soulsync_api_key: string;
soulsync_profile_id?: string;
custom_service_url: string;
custom_service_name: string;
auto_listen_on_launch: boolean;
+261 -38
View File
@@ -1,5 +1,9 @@
import { FormEvent } from "react";
import { FormEvent, useState } from "react";
import { Album, ExpandMore } from "@mui/icons-material";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Drawer,
@@ -7,11 +11,16 @@ import {
Input,
Link,
Paper,
Stack,
Switch,
Typography,
} from "@mui/material";
import lidarrLogo from "../../resources/lidarr.png";
import soulsyncLogo from "../../resources/soulsync.png";
import { useShazarrProvider } from "../Shazarr/useShazarr";
import { ConfigFieldsType } from "./context";
import { useConfigProvider } from "./useConfig";
interface Props {
@@ -19,6 +28,47 @@ interface Props {
onClose: () => void;
}
const SERVICE_GROUPS = [
{
id: "lidarr",
name: "Lidarr",
icon: (
<img
src={lidarrLogo}
alt="Lidarr"
style={{ width: 24, height: 24, borderRadius: "4px" }}
/>
),
fields: ["lidarr_url", "lidarr_api_key"],
},
{
id: "tidarr",
name: "Tidarr",
icon: <Album />,
fields: ["tidarr_url", "tidarr_api_key"],
},
{
id: "soulsync",
name: "SoulSync",
icon: (
<img
src={soulsyncLogo}
alt="SoulSync"
style={{ width: 24, height: 24, borderRadius: "4px" }}
/>
),
fields: ["soulsync_url", "soulsync_api_key", "soulsync_profile_id"],
},
{
id: "custom",
name: "Custom Service",
icon: null,
fields: ["custom_service_url", "custom_service_name"],
},
];
const SETTINGS_FIELDS = ["auto_listen_on_launch"];
export default function Config({ open, onClose }: Props) {
const { recordingStatus } = useShazarrProvider();
const {
@@ -27,6 +77,16 @@ export default function Config({ open, onClose }: Props) {
actions: { setConfig },
} = useConfigProvider();
const [expanded, setExpanded] = useState<string[]>([]);
const settingsFields = SETTINGS_FIELDS.map((fieldKey) => {
const fieldConfig = formConfig?.[fieldKey as keyof typeof formConfig];
return fieldConfig ? { key: fieldKey, config: fieldConfig } : null;
}).filter(Boolean) as Array<{
key: string;
config: ConfigFieldsType[keyof ConfigFieldsType];
}>;
if (!formConfig) return null;
function saveConfig(e: FormEvent<HTMLFormElement>) {
@@ -38,6 +98,9 @@ export default function Config({ open, onClose }: Props) {
lidarr_api_key: data.get("lidarr_api_key")?.toString() || "",
tidarr_url: data.get("tidarr_url")?.toString() || "",
tidarr_api_key: data.get("tidarr_api_key")?.toString() || "",
soulsync_url: data.get("soulsync_url")?.toString() || "",
soulsync_api_key: data.get("soulsync_api_key")?.toString() || "",
soulsync_profile_id: data.get("soulsync_profile_id")?.toString() || "",
custom_service_url: data.get("custom_service_url")?.toString() || "",
custom_service_name: data.get("custom_service_name")?.toString() || "",
auto_listen_on_launch: data.get("auto_listen_on_launch") === "on",
@@ -45,6 +108,85 @@ export default function Config({ open, onClose }: Props) {
onClose();
}
const handleAccordionChange = (groupId: string) => {
setExpanded((prev) => {
const isCurrentlyExpanded =
prev.includes(groupId);
if (isCurrentlyExpanded) {
return prev.filter((id) => id !== groupId);
} else {
return [...prev, groupId];
}
});
};
const isGroupExpanded = (groupId: string) => {
return expanded.includes(groupId);
};
const hasGroupValue = (groupId: string) => {
const group = SERVICE_GROUPS.find((g) => g.id === groupId);
if (!group) return false;
return group.fields.some((field) => config?.[field as keyof typeof config]);
};
const renderField = (
fieldKey: string,
fieldConfig: ConfigFieldsType[keyof ConfigFieldsType]
) => {
if (fieldConfig.type === "checkbox") {
return (
<FormControlLabel
key={fieldKey}
sx={{
marginLeft: 0,
justifyContent: "space-between",
width: "100%",
}}
labelPlacement="start"
control={
<Switch
name={fieldKey}
defaultChecked={Boolean(fieldConfig.value)}
/>
}
label={fieldConfig.placeholder}
/>
);
}
return (
<Input
key={fieldKey}
name={fieldKey}
type={fieldConfig.type}
sx={{ fontSize: "0.925rem" }}
defaultValue={fieldConfig.value as string | null}
placeholder={fieldConfig.placeholder}
fullWidth
/>
);
};
const GenericIcon = () => (
<Box
sx={{
width: 24,
height: 24,
backgroundColor: "primary.main",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "12px",
fontWeight: "bold",
}}
>
S
</Box>
);
return (
<Drawer
anchor="bottom"
@@ -54,57 +196,133 @@ export default function Config({ open, onClose }: Props) {
keepMounted: true,
}}
sx={{
"& .MuiPaper-root": {
maxHeight: "80vh",
},
}}
>
>
<Box
component="form"
onSubmit={(e) => saveConfig(e)}
sx={{
display: "flex",
alignSelf: "center",
display: "flex",
flexDirection: "column",
padding: 2,
padding: 1,
maxWidth: 480,
width: "100%",
}}>
{Object.entries(formConfig)?.map((field, index) => (
<Box sx={{ marginBottom: 2 }} key={`form-field-${index}`}>
<Paper sx={{ padding: "0.5rem" }}>
{field[1].type === "checkbox" ? (
<FormControlLabel
overflowY: "auto",
}}
>
{SERVICE_GROUPS.map((group) => {
const groupFields = group.fields
.map((fieldKey) => {
const fieldConfig = formConfig[fieldKey as keyof typeof formConfig];
return fieldConfig ? { key: fieldKey, config: fieldConfig } : null;
})
.filter(Boolean) as Array<{
key: string;
config: ConfigFieldsType[keyof ConfigFieldsType];
}>;
if (groupFields.length === 0) return null;
const hasValue = hasGroupValue(group.id);
const isExpanded = isGroupExpanded(group.id);
return (
<Box key={group.id} sx={{ marginBottom: 1 }}>
<Accordion
expanded={isExpanded}
onChange={() => handleAccordionChange(group.id)}
sx={{
boxShadow: "none",
border: "1px solid",
borderColor: "divider",
borderRadius: 1,
"&::before": {
display: "none",
},
}}
>
<AccordionSummary
expandIcon={<ExpandMore />}
sx={{
marginLeft: 0,
justifyContent: "space-between",
width: "100%",
minHeight: "48px",
"& .MuiAccordionSummary-content": {
margin: 0,
},
}}
labelPlacement="start"
control={
<Switch
name={field[0]}
defaultChecked={Boolean(field[1].value)}
/>
}
label={field[1].placeholder}
/>
) : (
<Input
name={field[0]}
type={field[1].type}
sx={{ fontSize: "0.925rem" }}
defaultValue={field[1].value as string | null}
placeholder={field[1].placeholder}
fullWidth
/>
)}
</Paper>
>
<Stack
direction="row"
spacing={1}
sx={{ width: "100%", alignItems: "center" }}
>
<Box
sx={{
width: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{group.icon ?? <GenericIcon />}
</Box>
<Typography variant="subtitle1" sx={{ flexGrow: 1 }}>
{group.name}
</Typography>
{hasValue && (
<Box
sx={{
width: 8,
height: 8,
backgroundColor: "success.main",
borderRadius: "50%",
right: 5,
position: "relative",
}}
/>
)}
</Stack>
</AccordionSummary>
<AccordionDetails>
<Paper
sx={{
padding: "0.5rem",
border: "none",
boxShadow: "none",
}}
>
{groupFields.map((field, index) => (
<Box
key={field.key}
sx={{
marginBottom: index < groupFields.length - 1 ? 1 : 0,
}}
>
{renderField(field.key, field.config)}
</Box>
))}
</Paper>
</AccordionDetails>
</Accordion>
</Box>
);
})}
{settingsFields.length > 0 && (
<Box sx={{ marginBottom: 1, padding: 1 }}>
{settingsFields.map((field) => renderField(field.key, field.config))}
</Box>
))}
<Box sx={{ textAlign: "center", paddingBottom: "10px" }}>
)}
<Box sx={{ textAlign: "center", paddingBottom: "10px", marginTop: 2 }}>
<Link
href="https://github.com/cstaelen/shazarr-app"
target="_blank"
rel="noreferrer"
sx={{fontSize: 12}}
sx={{ fontSize: 12 }}
>
Github page -{" "}
{import.meta.env.VITE_STAGE === "testing" || import.meta.env.MODE === "development"
@@ -113,7 +331,12 @@ export default function Config({ open, onClose }: Props) {
</Link>
</Box>
<Box>
<Button fullWidth type="submit" variant="contained" disabled={recordingStatus !== "inactive"}>
<Button
fullWidth
type="submit"
variant="contained"
disabled={recordingStatus !== "inactive"}
>
Save configuration
</Button>
</Box>
+13 -1
View File
@@ -11,6 +11,7 @@ import ButtonLyrics from "./ui/ButtonLyrics";
import CardResult from "./ui/Card";
import CustomServiceButton from "./ui/CustomServiceButton";
import LidarrButton from "./ui/LidarrButton";
import SoulsyncButton from "./ui/SoulsyncButton";
import StreamProviderButton from "./ui/StreamProviderButton";
import TagHistory from "./ui/TagHistory";
import TidarrButton from "./ui/TidarrButton";
@@ -102,6 +103,15 @@ export default function ShazarrResults() {
url={config.tidarr_url as string}
/>
)}
{config?.soulsync_url && (
<SoulsyncButton
key={`soulsync-${data.key}`}
albumTitle={albumName || data.title}
trackTitle={data.title}
artistName={data.subtitle}
url={config.soulsync_url as string}
/>
)}
{config?.custom_service_url && config?.custom_service_name && (
<CustomServiceButton
searchTerms={`${data.title} ${data.subtitle}`}
@@ -110,7 +120,9 @@ export default function ShazarrResults() {
/>
)}
{config?.lidarr_url && config?.tidarr_url && config?.custom_service_url && config?.custom_service_name && (<Divider />)}
{(config?.lidarr_url || config?.tidarr_url || config?.soulsync_url || (config?.custom_service_url && config?.custom_service_name)) && (
<Divider />
)}
<Box sx={{ textAlign: "center" }}>
{data?.hub?.providers?.map(
+276
View File
@@ -0,0 +1,276 @@
import { normalize } from "./utils";
type SoulsyncConfig = {
url: string;
apiKey?: string;
profileId?: number;
};
export type SoulsyncQueueResult =
| { success: true; message?: string }
| { success: false; message: string };
/**
* Generic request for SoulSync API v1
* Handles auth via Bearer header or api_key query param
*/
async function request<T>(
config: SoulsyncConfig,
path: string,
options: RequestInit = {}
): Promise<{
success: boolean;
data?: T;
error?: { code: string; message: string };
pagination?: unknown;
}> {
const baseUrl = config.url.replace(/\/$/, "");
const url = new URL(`${baseUrl}${path}`);
if (config.apiKey && !url.searchParams.has("api_key")) {
url.searchParams.set("api_key", config.apiKey);
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (config.apiKey) {
headers["Authorization"] = `Bearer ${config.apiKey}`;
}
if (config.profileId) {
headers["X-Profile-Id"] = String(config.profileId);
}
try {
const res = await fetch(url.toString(), {
...options,
headers,
});
if (!res.ok) {
const text = await res.text();
try {
const errorData = JSON.parse(text);
throw new Error(
`SoulSync API error (${res.status}): ${errorData.error?.message || text}`
);
} catch {
throw new Error(`SoulSync API error (${res.status}): ${text}`);
}
}
const content = await res.text();
if (!content) return { success: true } as const;
return JSON.parse(content);
} catch (e) {
throw new Error(
`SoulSync unreachable: ${e instanceof Error ? e.message : "Unknown error"}`,
{ cause: e }
);
}
}
/**
* Search tracks via SoulSync external search (Spotify/iTunes/Hydrabase)
*/
export async function soulsyncSearchTracks(
url: string,
query: string,
apiKey?: string,
profileId?: number,
limit: number = 5
): Promise<{
success: boolean;
tracks?: Array<{
id: string;
name: string;
artists: string[];
album: string;
duration_ms?: number;
image_url?: string;
source: string;
}>;
error?: string;
}> {
const config: SoulsyncConfig = { url, apiKey, profileId };
try {
const result = await request<{
tracks?: Array<{
id: string;
name: string;
artists: string[];
album: string;
duration_ms?: number;
image_url?: string;
source: string;
}>;
}>(config, "/api/v1/search/tracks", {
method: "POST",
body: JSON.stringify({ query, source: "auto", limit }),
});
if (result.success && result.data?.tracks) {
return { success: true, tracks: result.data.tracks };
}
return { success: false, error: result.error?.message || "No tracks found" };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Search failed" };
}
}
/**
* Add a track to SoulSync wishlist (official API: POST /api/v1/wishlist)
* This triggers automatic download processing
*/
export async function soulsyncAddTrackToWishlist(
url: string,
trackTitle: string,
artistName: string,
apiKey?: string,
profileId?: number
): Promise<SoulsyncQueueResult> {
const config: SoulsyncConfig = { url, apiKey, profileId };
try {
// Step 1: Search for track to get Spotify data
const searchResult = await soulsyncSearchTracks(
url,
`${artistName} - ${trackTitle}`,
apiKey,
profileId,
1
);
if (!searchResult.success || !searchResult.tracks?.length) {
return {
success: false,
message: `Track not found: ${searchResult.error || "No match"}`,
};
}
const track = searchResult.tracks[0];
// Step 2: Add to wishlist with required Spotify track data
const result = await request(config, "/api/v1/wishlist", {
method: "POST",
body: JSON.stringify({
spotify_track_data: {
id: track.id,
name: track.name,
artists: track.artists.map((name) => ({ name })),
album: { name: track.album, album_type: "album" },
duration_ms: track.duration_ms,
image_url: track.image_url,
},
failure_reason: null,
source_type: "api",
}),
});
if (result.success) {
return { success: true, message: "Track added to SoulSync wishlist!" };
}
return { success: false, message: result.error?.message || "Add failed" };
} catch (e) {
return {
success: false,
message: e instanceof Error ? e.message : "Failed to add track",
};
}
}
/**
* Add an album to SoulSync wishlist (adds all album tracks)
* This triggers automatic download processing for all tracks
*/
export async function soulsyncAddAlbumToWishlist(
url: string,
albumTitle: string,
artistName: string,
apiKey?: string,
profileId?: number
): Promise<SoulsyncQueueResult> {
const config: SoulsyncConfig = { url, apiKey, profileId };
try {
// Search for tracks from this album
const searchResult = await soulsyncSearchTracks(
url,
`${artistName} - ${albumTitle}`,
apiKey,
profileId,
25
);
if (!searchResult.success || !searchResult.tracks?.length) {
return {
success: false,
message: `Album not found: ${searchResult.error || "No match"}`,
};
}
// Filter tracks from this album
const albumTracks = searchResult.tracks.filter(
(t) =>
normalize(t.album) === normalize(albumTitle) &&
t.artists.some((a) => normalize(a) === normalize(artistName))
);
if (albumTracks.length === 0) {
// Fallback: try with first result
const firstTrack = searchResult.tracks[0];
const result = await request(config, "/api/v1/wishlist", {
method: "POST",
body: JSON.stringify({
spotify_track_data: {
id: firstTrack.id,
name: firstTrack.name,
artists: firstTrack.artists.map((n) => ({ name: n })),
album: { name: firstTrack.album, album_type: "album" },
image_url: firstTrack.image_url,
},
failure_reason: null,
source_type: "api",
}),
});
if (result.success) {
return { success: true, message: `Added 1 track from "${albumTitle}"` };
}
return { success: false, message: result.error?.message || "Failed" };
}
// Add all album tracks
let addedCount = 0;
for (const track of albumTracks) {
const result = await request(config, "/api/v1/wishlist", {
method: "POST",
body: JSON.stringify({
spotify_track_data: {
id: track.id,
name: track.name,
artists: track.artists.map((n) => ({ name: n })),
album: { name: track.album, album_type: "album" },
duration_ms: track.duration_ms,
image_url: track.image_url,
},
failure_reason: null,
source_type: "api",
}),
});
if (result.success) addedCount++;
}
return {
success: addedCount > 0,
message: `Added ${addedCount} tracks to wishlist`,
};
} catch (e) {
return {
success: false,
message: e instanceof Error ? e.message : "Failed to add album",
};
}
}
@@ -0,0 +1,233 @@
import { useState } from "react";
import { OpenInNew } from "@mui/icons-material";
import {
Box,
Button,
CircularProgress,
Dialog,
DialogContent,
DialogTitle,
IconButton,
Stack,
Tooltip,
Typography,
} from "@mui/material";
import soulsyncLogo from "../../../resources/soulsync.png";
import { useConfigProvider } from "../../Config/useConfig";
import {
soulsyncAddAlbumToWishlist,
soulsyncAddTrackToWishlist,
SoulsyncQueueResult,
} from "../api/soulsync";
function formatSoulsyncError(message: string): string {
if (!message) return "Error — try manually";
// Network errors
if (/failed to fetch|network|networkerror/i.test(message)) return "SoulSync unreachable";
if (/<!doctype|not valid json|unexpected token/i.test(message)) return "SoulSync unreachable";
// Auth errors
if (/401|unauthorized/i.test(message)) return "Invalid credentials";
if (/403|forbidden/i.test(message)) return "Access denied";
// Not found
if (/404/i.test(message)) return "SoulSync not found — check URL";
// Server errors
if (/5\d\d|service unavailable|bad gateway/i.test(message)) return "SoulSync unreachable";
// Default: return the original message
return message;
}
type SoulsyncStatus = "idle" | "searching" | "loading" | "success" | "error";
// SoulSync logo component
function SoulsyncLogoIcon() {
return <img src={soulsyncLogo} alt="SoulSync" style={{ width: 32, height: 32 }} />;
}
export default function SoulsyncButton({
albumTitle,
trackTitle,
artistName,
url,
}: {
albumTitle: string;
trackTitle: string;
artistName: string;
url: string;
}) {
const { isNetworkConnected, config } = useConfigProvider();
const [status, setStatus] = useState<SoulsyncStatus>("idle");
const [message, setMessage] = useState<string>();
const [dialogOpen, setDialogOpen] = useState(false);
const apiKey = config?.soulsync_api_key;
const profileId = config?.soulsync_profile_id
? parseInt(config.soulsync_profile_id, 10)
: undefined;
async function handleAdd(type: "track" | "album") {
setDialogOpen(false);
setStatus("searching");
setMessage("");
const effectiveAlbumTitle = albumTitle || trackTitle;
let result: SoulsyncQueueResult;
if (type === "track") {
result = await soulsyncAddTrackToWishlist(
url,
trackTitle,
artistName,
apiKey,
profileId
);
} else {
result = await soulsyncAddAlbumToWishlist(
url,
effectiveAlbumTitle,
artistName,
apiKey,
profileId
);
}
handleResult(result);
}
function handleResult(result: SoulsyncQueueResult) {
setMessage(result.success ? result.message : formatSoulsyncError(result.message));
setStatus(result.success ? "success" : "error");
}
function handleClick() {
if (!apiKey) {
window.open(url, "_blank");
return;
}
setDialogOpen(true);
}
const label = {
idle: "SoulSync",
searching: "Searching...",
loading: "Adding...",
success: message || "Added!",
error: message || "Error — try manually",
}[status];
const searchUrl = `${url}`;
return (
<>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
fullWidth
disabled={!isNetworkConnected || status === "loading"}
startIcon={
status === "loading" ? (
<CircularProgress size={20} color="inherit" />
) : (
<SoulsyncLogoIcon />
)
}
color={
status === "success"
? "success"
: status === "error"
? "warning"
: "primary"
}
onClick={handleClick}
data-testid="soulsync-button"
>
<strong>{label}</strong>
</Button>
{apiKey && (
<Tooltip title="Open SoulSync">
<span>
<IconButton
color="primary"
onClick={() => window.open(searchUrl, "_blank")}
disabled={!isNetworkConnected}
sx={{ border: 2, borderRadius: 1 }}
>
<OpenInNew fontSize="medium" />
</IconButton>
</span>
</Tooltip>
)}
</Stack>
{/* Dialog to choose between Track and Album */}
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
fullWidth
maxWidth="xs"
data-testid="soulsync-dialog"
>
<DialogTitle>Add to SoulSync</DialogTitle>
<DialogContent sx={{ padding: 1 }}>
<Stack spacing={1}>
<Box
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 1,
overflow: "hidden",
}}
>
<Button
fullWidth
variant="text"
onClick={() => handleAdd("track")}
sx={{ justifyContent: "flex-start", padding: 2 }}
startIcon={<SoulsyncLogoIcon />}
data-testid="soulsync-add-track"
>
<Typography variant="body1">
<strong>Add Track</strong>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ ml: 2 }}>
{trackTitle}
</Typography>
</Button>
</Box>
<Box
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 1,
overflow: "hidden",
}}
>
<Button
fullWidth
variant="text"
onClick={() => handleAdd("album")}
sx={{ justifyContent: "flex-start", padding: 2 }}
startIcon={<SoulsyncLogoIcon />}
data-testid="soulsync-add-album"
>
<Typography variant="body1">
<strong>Add Album</strong>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ ml: 2 }}>
{albumTitle || trackTitle}
</Typography>
</Button>
</Box>
</Stack>
</DialogContent>
</Dialog>
</>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+7 -1
View File
@@ -33,6 +33,12 @@ test("Options: Should see options panel and use service buttons", async ({
// Open config panel
await page.getByRole("button", { name: "Configuration" }).click();
// Wait for accordions to be visible and expand them
await expect(page.getByText("Lidarr")).toBeVisible({ timeout: 5000 });
await page.getByText("Lidarr").click();
await page.getByText("Tidarr").click();
await page.getByText("Custom Service").click();
// Fill fields and save
await page
.getByPlaceholder("Lidarr URL (http://...)")
@@ -58,7 +64,7 @@ test("Options: Should see options panel and use service buttons", async ({
await waitForImgLoaded(page);
await page.waitForTimeout(1000);
await expect(page.getByText("Download with LidarrDownload")).toHaveScreenshot(
await expect(page.getByText("Download with Lidarr")).toHaveScreenshot(
{ maxDiffPixelRatio: 0.05 },
);
+170
View File
@@ -0,0 +1,170 @@
import test, { expect, Page } from "@playwright/test";
import historyFixtures from "./fixtures/history.json";
const SOULSYNC_URL = "http://soulsync.local:8008";
const SOULSYNC_API_KEY = "test-soulsync-api-key";
async function gotoWithConfig(page: Page, config: Record<string, string>) {
await page.addInitScript(
({ config, history }) => {
window.localStorage.setItem(
"CapacitorStorage.com.shazarr.config",
JSON.stringify(config),
);
window.localStorage.setItem(
"CapacitorStorage.com.shazarr.history",
JSON.stringify(history),
);
},
{ config, history: historyFixtures },
);
await page.goto("/");
await expect(page.getByText("Ready")).toBeInViewport();
}
async function openYakuzaResult(page: Page) {
await page.getByRole("button", { name: "Records" }).click();
await page.getByTestId("history-item").nth(2).getByRole("button").first().click();
await expect(page.locator(".MuiTypography-h5")).toHaveText("Yakuza");
}
async function openModjoResult(page: Page) {
await page.getByRole("button", { name: "Records" }).click();
await page.getByTestId("history-item").nth(0).getByRole("button").first().click();
await expect(page.locator(".MuiTypography-h5")).toHaveText("Chillin'");
}
const MOCK_TRACK = {
id: "6LgJvl0Xdtc73RJ1mN1a7Z",
name: "Paranoid Android",
artists: ["Radiohead"],
album: "OK Computer",
duration_ms: 383000,
image_url: "https://i.scdn.co/image/...",
source: "spotify",
};
const MOCK_ALBUM_TRACKS = [
{ ...MOCK_TRACK, track_number: 1 },
{
id: "2cGxRwrMyEAp8dEbuZaVv6",
name: "Airbag",
artists: ["Radiohead"],
album: "OK Computer",
duration_ms: 428000,
image_url: "https://i.scdn.co/image/...",
source: "spotify",
},
];
function mockSoulsyncRoutes(page: Page) {
return page.route(`${SOULSYNC_URL}/**`, async (route) => {
const url = route.request().url();
const method = route.request().method();
if (url.includes("/api/v1/search/tracks") && method === "POST") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true, data: { tracks: MOCK_ALBUM_TRACKS } }),
});
} else if (url.includes("/api/v1/wishlist") && method === "POST") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true }),
});
} else {
await route.continue();
}
});
}
function mockSoulsyncNotFound(page: Page) {
return page.route(`${SOULSYNC_URL}/**`, async (route) => {
const url = route.request().url();
const method = route.request().method();
if (url.includes("/api/v1/search/tracks") && method === "POST") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: false, error: { code: "NOT_FOUND", message: "No tracks found" } }),
});
} else if (url.includes("/api/v1/wishlist") && method === "POST") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true }),
});
} else {
await route.continue();
}
});
}
test("SoulSync: Without API key — opens browser search", async ({ page }) => {
await gotoWithConfig(page, { soulsync_url: SOULSYNC_URL });
await openYakuzaResult(page);
await expect(page.getByTestId("soulsync-button")).toBeVisible();
await expect(page.getByTestId("soulsync-button")).toBeEnabled();
const ctx = page.context();
const pagePromise = ctx.waitForEvent("page");
await page.getByTestId("soulsync-button").click();
const newPage = await pagePromise;
await newPage.waitForLoadState();
// In headless mode, Chrome returns chrome-error://chromewebdata/ for external URLs
// Verify the page was opened (chrome-error:// confirms external URL was attempted)
expect(newPage.url()).toMatch(/chrome-error:\/\//);
await newPage.close();
});
test("SoulSync: With API key — opens dialog for track/album choice", async ({ page }) => {
await mockSoulsyncRoutes(page);
await gotoWithConfig(page, { soulsync_url: SOULSYNC_URL, soulsync_api_key: SOULSYNC_API_KEY });
await openYakuzaResult(page);
await expect(page.getByTestId("soulsync-button")).toBeVisible();
await expect(page.getByTestId("soulsync-button")).toBeEnabled();
await page.getByTestId("soulsync-button").click();
await expect(page.getByTestId("soulsync-dialog")).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("soulsync-add-track")).toBeVisible();
await expect(page.getByTestId("soulsync-add-album")).toBeVisible();
});
test("SoulSync: With API key — adds track to wishlist and shows success", async ({ page }) => {
await mockSoulsyncRoutes(page);
await gotoWithConfig(page, { soulsync_url: SOULSYNC_URL, soulsync_api_key: SOULSYNC_API_KEY });
await openYakuzaResult(page);
await expect(page.getByTestId("soulsync-button")).toBeVisible();
await expect(page.getByTestId("soulsync-button")).toBeEnabled();
await page.getByTestId("soulsync-button").click();
await expect(page.getByTestId("soulsync-dialog")).toBeVisible({ timeout: 10000 });
await page.getByTestId("soulsync-add-track").click();
// Skip "Searching" check - too fast in CI, goes straight to success
await expect(page.getByTestId("soulsync-button")).toContainText(/Added|added|wishlist/i, { timeout: 20000 });
});
test("SoulSync: With API key — adds album to wishlist and shows success", async ({ page }) => {
await mockSoulsyncRoutes(page);
await gotoWithConfig(page, { soulsync_url: SOULSYNC_URL, soulsync_api_key: SOULSYNC_API_KEY });
await openYakuzaResult(page);
await expect(page.getByTestId("soulsync-button")).toBeVisible();
await expect(page.getByTestId("soulsync-button")).toBeEnabled();
await page.getByTestId("soulsync-button").click();
await expect(page.getByTestId("soulsync-dialog")).toBeVisible({ timeout: 10000 });
await page.getByTestId("soulsync-add-album").click();
// Skip "Searching" check - too fast in CI, goes straight to success
await expect(page.getByTestId("soulsync-button")).toContainText(/Added|tracks from|to SoulSync/i, { timeout: 20000 });
});
test("SoulSync: With API key — shows error when track not found", async ({ page }) => {
await mockSoulsyncNotFound(page);
await gotoWithConfig(page, { soulsync_url: SOULSYNC_URL, soulsync_api_key: SOULSYNC_API_KEY });
await openModjoResult(page);
await expect(page.getByTestId("soulsync-button")).toBeVisible();
await expect(page.getByTestId("soulsync-button")).toBeEnabled();
await page.getByTestId("soulsync-button").click();
await expect(page.getByTestId("soulsync-dialog")).toBeVisible({ timeout: 10000 });
await page.getByTestId("soulsync-add-track").click();
// Skip "Searching" check - too fast in CI, goes straight to error
await expect(page.getByTestId("soulsync-button")).toContainText(/not found|No match|Error|unreachable/i, { timeout: 20000 });
});
+1 -1
View File
@@ -3,7 +3,7 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,