mirror of
https://github.com/cstaelen/shazarr.git
synced 2026-10-18 17:20:21 -05:00
feat/soulsync-support (#365)
This commit is contained in:
1 parent
c679154bbf
commit
6347e7be49
17 files changed
+1582
-835
No files matched your search
@@ -13,6 +13,10 @@
|
||||
/build
|
||||
/dist
|
||||
|
||||
#docker tests
|
||||
.docker
|
||||
docker-compose.yml
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
Generated
+578
-784
File diff suppressed because it is too large.
Load diff
@@ -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=)",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 2.7 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 23 KiB |
@@ -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 },
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
|
||||
Reference in new issue
Block a user