Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BrainzPlayer album-aware music search #3014

Open
wants to merge 5 commits into
base: ansh/improve-apple-music-match
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 58 additions & 6 deletions frontend/js/src/common/brainzplayer/AppleMusicPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,34 @@ import { Link } from "react-router-dom";
import fuzzysort from "fuzzysort";
import {
getArtistName,
getReleaseName,
getTrackName,
loadScriptAsync,
} from "../../utils/utils";
import { DataSourceProps, DataSourceType } from "./BrainzPlayer";
import GlobalAppContext from "../../utils/GlobalAppContext";
import { dataSourcesInfo } from "../../settings/brainzplayer/BrainzPlayerSettings";

export type AppleMusicPlayerProps = DataSourceProps;
export type AppleMusicPlayerProps = DataSourceProps & {
handleAlbumMapping: (
dataSource: keyof MatchedTrack,
releaseName: string,
album: {
trackName: string;
uri: string;
}[]
) => void;
getAlbumMapping: (
listen: BrainzPlayerQueueItem,
dataSource: keyof MatchedTrack
) => string | undefined;
};

export type AppleMusicPlayerState = {
currentAppleMusicTrack?: MusicKit.MediaItem;
progressMs: number;
durationMs: number;
listen?: BrainzPlayerQueueItem;
};
export async function loadAppleMusicKit(): Promise<void> {
if (!window.MusicKit) {
Expand Down Expand Up @@ -173,10 +188,15 @@ export default class AppleMusicPlayer
return;
}
try {
await this.appleMusicPlayer.setQueue({
const queueData = await this.appleMusicPlayer.setQueue({
song: appleMusicId,
startPlaying: true,
});
const albumId = queueData.item(0)?.relationships?.albums?.data?.[0]?.id;

if (albumId) {
this.fetchAlbumTracksAndUpdateMappings(albumId);
}
} catch (error) {
handleError(error.message, "Error playing on Apple Music");
onTrackNotFound();
Expand All @@ -188,7 +208,7 @@ export default class AppleMusicPlayer
return AppleMusicPlayer.hasPermissions(appleMusicUser);
};

searchAndPlayTrack = async (listen: Listen | JSPFTrack): Promise<void> => {
searchAndPlayTrack = async (listen: BrainzPlayerQueueItem): Promise<void> => {
if (!this.appleMusicPlayer) {
await this.connectAppleMusicPlayer();
await this.searchAndPlayTrack(listen);
Expand Down Expand Up @@ -258,12 +278,14 @@ export default class AppleMusicPlayer
return false;
};

playListen = async (listen: Listen | JSPFTrack): Promise<void> => {
const { show } = this.props;
playListen = async (listen: BrainzPlayerQueueItem): Promise<void> => {
const { show, getAlbumMapping } = this.props;
if (!show) {
return;
}
const apple_music_id = AppleMusicPlayer.getURLFromListen(listen as Listen);
this.setState({ listen });

const apple_music_id = getAlbumMapping(listen, "appleMusic");
if (apple_music_id) {
await this.playAppleMusicId(apple_music_id);
return;
Expand Down Expand Up @@ -448,6 +470,36 @@ export default class AppleMusicPlayer
this.setState({ currentAppleMusicTrack: item });
};

fetchAlbumTracksAndUpdateMappings = async (albumId: string) => {
// Exptract the album id from the url
const { listen } = this.state;
const releaseName = getReleaseName(listen as Listen);

if (!releaseName || !albumId) {
return;
}
const { handleAlbumMapping } = this.props;

const response = await this.appleMusicPlayer?.api.music(
`/v1/catalog/{{storefrontId}}/albums/${albumId}`
);

// @ts-ignore
const tracks = response?.data?.data?.[0]?.relationships?.tracks
?.data as MusicKit.Song[];

if (!tracks || tracks?.length === 0) {
return;
}

const trackMappings = tracks.map((track) => ({
uri: track.id,
trackName: track.attributes?.name,
}));

handleAlbumMapping("appleMusic", releaseName, trackMappings);
};

getAlbumArt = (): JSX.Element | null => {
const { currentAppleMusicTrack } = this.state;
if (
Expand Down
123 changes: 122 additions & 1 deletion frontend/js/src/common/brainzplayer/BrainzPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as React from "react";
import { toast } from "react-toastify";
import { Link } from "react-router-dom";
import { Helmet } from "react-helmet";
import Fuse from "fuse.js";
import {
ToastMsg,
createNotification,
Expand All @@ -39,11 +40,16 @@ import {
useBrainzPlayerContext,
useBrainzPlayerDispatch,
} from "./BrainzPlayerContext";
import {
getReleaseMBID,
getReleaseName,
getTrackName,
} from "../../utils/utils";

export type DataSourceType = {
name: string;
icon: IconProp;
playListen: (listen: Listen | JSPFTrack) => void;
playListen: (listen: BrainzPlayerQueueItem) => void;
togglePlay: () => void;
seekToPositionMs: (msTimecode: number) => void;
canSearchAndPlayTracks: () => boolean;
Expand Down Expand Up @@ -160,6 +166,7 @@ export default function BrainzPlayer() {
queue,
ambientQueue,
queueRepeatMode,
albumMapping,
} = useBrainzPlayerContext();

const dispatch = useBrainzPlayerDispatch();
Expand Down Expand Up @@ -246,6 +253,8 @@ export default function BrainzPlayer() {
playerPausedRef.current = playerPaused;
const continuousPlaybackTimeRef = React.useRef(continuousPlaybackTime);
continuousPlaybackTimeRef.current = continuousPlaybackTime;
const albumMappingRef = React.useRef(albumMapping);
albumMappingRef.current = albumMapping;

// Functions
const alertBeforeClosingPage = (event: BeforeUnloadEvent) => {
Expand Down Expand Up @@ -945,6 +954,114 @@ export default function BrainzPlayer() {
}
};

/**
* Stores mappings between tracks from different music services for the same album.
* This allows the player to find equivalent tracks across different services.
*
* @param dataSource - The music service ('spotify', 'appleMusic', 'youtube')
* @param releaseName - The name of the album/release
* @param album - Array of tracks in the album, each with a name and service-specific URI
*/
const handleAlbumMapping = React.useCallback(
(
dataSource: keyof MatchedTrack,
releaseName: string,
album: {
trackName: string;
uri: string;
}[]
): void => {
dispatch({
type: "ADD_ALBUM_MAPPING",
data: {
dataSource,
releaseName,
album,
},
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);

/**
* Retrieves the URI for a specific track from a given music service.
* This function uses fuzzy matching to find the closest match for the release and track names.
*
* @param listen - The listen object containing the release name and track name
* @param dataSource - The music service (e.g., 'spotify', 'appleMusic')
* @returns The URI for the track, or undefined if no match is found
*/
const getAlbumMapping = React.useCallback(
(
listen: BrainzPlayerQueueItem,
dataSource: keyof MatchedTrack
): string | undefined => {
const releaseName = getReleaseName(listen);
const trackName = getTrackName(listen);
const existingReleases = Object.keys(albumMappingRef.current);
if (!existingReleases.length) {
return undefined;
}

// First, find matching release
const releaseOptions = {
includeScore: true,
threshold: 0.3,
keys: ["releaseName"],
};

const releaseFuse = new Fuse(
existingReleases.map((name) => ({ releaseName: name })),
releaseOptions
);

const releaseMatches = releaseFuse.search(releaseName);

// If we find a matching release
if (
releaseMatches.length > 0 &&
releaseMatches[0].score &&
releaseMatches[0].score < 0.3
) {
const matchedReleaseName = releaseMatches[0].item.releaseName;
const tracksInRelease = Object.keys(
albumMappingRef.current[matchedReleaseName]
);

// Then, find matching track within that release
const trackOptions = {
threshold: 0.3,
keys: ["trackName"],
};

const trackFuse = new Fuse(
tracksInRelease.map((name) => ({ trackName: name })),
trackOptions
);

const trackMatches = trackFuse.search(trackName);

// If we find a matching track, return its URI for the requested data source
if (
trackMatches.length > 0 &&
trackMatches[0].score &&
trackMatches[0].score < 0.3
) {
const matchedTrackName = trackMatches[0].item.trackName;

return albumMappingRef.current[matchedReleaseName][matchedTrackName][
dataSource
];
}
}

return undefined;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);

React.useEffect(() => {
window.addEventListener("storage", onLocalStorageEvent);
window.addEventListener("message", receiveBrainzPlayerMessage);
Expand Down Expand Up @@ -1029,6 +1146,8 @@ export default function BrainzPlayer() {
handleError={handleError}
handleWarning={handleWarning}
handleSuccess={handleSuccess}
handleAlbumMapping={handleAlbumMapping}
getAlbumMapping={getAlbumMapping}
/>
)}
{userPreferences?.brainzplayer?.youtubeEnabled !== false && (
Expand Down Expand Up @@ -1095,6 +1214,8 @@ export default function BrainzPlayer() {
handleError={handleError}
handleWarning={handleWarning}
handleSuccess={handleSuccess}
handleAlbumMapping={handleAlbumMapping}
getAlbumMapping={getAlbumMapping}
/>
)}
</BrainzPlayerUI>
Expand Down
58 changes: 57 additions & 1 deletion frontend/js/src/common/brainzplayer/BrainzPlayerContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from "react";
import { faRepeat } from "@fortawesome/free-solid-svg-icons";
import { isEqual, isNil } from "lodash";
import Fuse from "fuse.js";
import { faRepeatOnce } from "../../utils/icons";
import { listenOrJSPFTrackToQueueItem } from "./utils";

Expand Down Expand Up @@ -48,6 +49,7 @@ export type BrainzPlayerContextT = {
queue: BrainzPlayerQueue;
ambientQueue: BrainzPlayerQueue;
queueRepeatMode: QueueRepeatMode;
albumMapping: AlbumMapping;
};

export const initialValue: BrainzPlayerContextT = {
Expand All @@ -65,6 +67,7 @@ export const initialValue: BrainzPlayerContextT = {
queue: [],
ambientQueue: [],
queueRepeatMode: QueueRepeatModes.off,
albumMapping: {},
};

export type BrainzPlayerActionType = Partial<BrainzPlayerContextT> & {
Expand All @@ -81,7 +84,8 @@ export type BrainzPlayerActionType = Partial<BrainzPlayerContextT> & {
| "ADD_LISTEN_TO_TOP_OF_QUEUE"
| "ADD_LISTEN_TO_BOTTOM_OF_QUEUE"
| "ADD_LISTEN_TO_BOTTOM_OF_AMBIENT_QUEUE"
| "ADD_MULTIPLE_LISTEN_TO_BOTTOM_OF_AMBIENT_QUEUE";
| "ADD_MULTIPLE_LISTEN_TO_BOTTOM_OF_AMBIENT_QUEUE"
| "ADD_ALBUM_MAPPING";
data?: any;
};

Expand Down Expand Up @@ -317,6 +321,58 @@ function valueReducer(
ambientQueue: [...ambientQueue, ...tracksToAdd],
};
}
case "ADD_ALBUM_MAPPING": {
const { dataSource, releaseName, album } = action.data as {
dataSource: keyof MatchedTrack;
releaseName: string;
album: {
trackName: string;
uri: string;
}[];
};
const { albumMapping } = state;

// Initialize Fuse for fuzzy matching release names
const fuseOptions = {
threshold: 0.3,
distance: 100,
keys: ["releaseName"],
};

const existingReleases = Object.keys(albumMapping).map((key) => ({
releaseName: key,
}));

const fuse = new Fuse(existingReleases, fuseOptions);
const matches = fuse.search(releaseName);

let targetReleaseName = releaseName;
// If we find a close match, use that release name instead
if (matches.length > 0 && matches[0].score && matches[0].score < 0.3) {
targetReleaseName = matches[0].item.releaseName;
}

// Create or update the release mapping
const updatedReleaseMapping = {
...(albumMapping[targetReleaseName] || {}),
};

// Add each track's URI under the appropriate dataSource
album.forEach(({ trackName, uri }) => {
updatedReleaseMapping[trackName] = {
...(updatedReleaseMapping[trackName] || {}),
[dataSource]: uri,
};
});

return {
...state,
albumMapping: {
...albumMapping,
[targetReleaseName]: updatedReleaseMapping,
},
};
}
default: {
throw Error(`Unknown action: ${action.type}`);
}
Expand Down
Loading