Skip to content

Commit

Permalink
Support setlist search
Browse files Browse the repository at this point in the history
  • Loading branch information
elamperti committed Dec 25, 2024
1 parent 95b97c3 commit 3e28103
Show file tree
Hide file tree
Showing 26 changed files with 351 additions and 257 deletions.
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ SQLITE_DB_PATH="/var/www/html/database/development.db"
# Optional
DISCOGS_API_KEY=
DISCOGS_SECRET=
SETLISTFM_API_KEY=
REACT_APP_ANALYTICS_MEASUREMENT_ID=
REACT_APP_GROWTHBOOK_API_KEY=
REACT_APP_LINK_TO_TRANSLATIONS="https://github.com/elamperti/OpenWebScrobbler/blob/main/CONTRIBUTING.md#translations"
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ To join the translators team, follow the link at the bottom of the language sele
* Clone the repository
* Copy `.env` to `.env.development.local`, then set at least the required variables
* You'll need a [Last.fm API account](https://www.last.fm/api/account/create) to be able to interact with Open Scrobbler (it's used for authentication and queries). Once you have your keys, fill in `REACT_APP_LASTFM_API_KEY` and `LASTFM_API_KEY` (same value in both) and `LASTFM_SECRET`
* Optional: to interact with Discogs, create a [Discogs application](https://www.discogs.com/settings/developers) to get API keys and fill in `DISCOGS_API_KEY` and `DISCOGS_SECRET`
* Optional: to interact with Discogs, create a [Discogs application](https://www.discogs.com/settings/developers) to get API keys and fill in `DISCOGS_API_KEY` and `DISCOGS_SECRET`
* Optional: to interact with Setlist.fm, [create an application](https://www.setlist.fm/settings/apps) and set `SETLISTFM_API_KEY` with the corresponding API key
* Run `yarn` to download the required libraries.
* Run `yarn start` to initialize the docker container and run the application.

Expand Down
39 changes: 24 additions & 15 deletions public/api/v2/setlistfm.php
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
<?php
if (isset($_GET['setlist_id'])) {
$params = array();
$endpoint = 'setlist/' . $_GET['setlist_id'];
if (isset($_GET['setlistId'])) {
$sanitized_setlistId = filter_var($_GET['setlistId'], FILTER_SANITIZE_STRING);
$endpoint = 'setlist/' . $_GET['setlistId'];
} else if (isset($_GET['artistName'])) {
$sanitized_artistName = filter_var($_GET['artistName'], FILTER_SANITIZE_STRING);
$endpoint = 'search/setlists?artistName=' . urlencode($sanitized_artistName);

if (isset($_GET['page'])) {
$sanitized_p = filter_var($_GET['page'], FILTER_SANITIZE_NUMBER_INT);
$endpoint .= '&p=' . $sanitized_p;
}
} else {
require('inc/error.php');
raiseOWSError('No setlist ID provided', 404, 620);
}

$setlistfmrq = curl_init();

curl_setopt($setlistfmrq, CURLOPT_HTTPHEADER, [
"REMOTE_ADDR: " . $_SERVER['REMOTE_ADDR'],
"HTTP_X_FORWARDED_FOR: " . $_SERVER['REMOTE_ADDR'],
"Accept: application/json",
"x-api-key: " . getenv('SETLISTFM_API_KEY')
]);
curl_setopt($setlistfmrq, CURLOPT_RETURNTRANSFER, true);
curl_setopt($setlistfmrq, CURLOPT_HTTPHEADER, ["REMOTE_ADDR: $_SERVER[REMOTE_ADDR]", "HTTP_X_FORWARDED_FOR: $_SERVER[REMOTE_ADDR]", "Accept: application/json", "x-api-key: " . getenv('SETLISTFM_API_KEY')]);

$qs = http_build_query($params);

$fullRequest = 'https://api.setlist.fm/rest/1.0' . '/' . $endpoint . '?' . $qs;
curl_setopt($setlistfmrq, CURLOPT_URL, 'https://api.setlist.fm/rest/1.0/' . $endpoint);

curl_setopt($setlistfmrq, CURLOPT_URL, $fullRequest);

$response = curl_exec($setlistfmrq);
header('Content-Type: ' . curl_getinfo($setlistfmrq, CURLINFO_CONTENT_TYPE));

require_once('inc/analytics.php');
$ga = new Analytics();
$ga->timing('Setlist.fm Response Time', $method, round(curl_getinfo($setlistfmrq, CURLINFO_TOTAL_TIME) * 1000));

curl_close($setlistfmrq);
echo $response;
} else {
require('inc/error.php');
raiseOWSError('No setlist ID provided', 404, 602);
}

3 changes: 1 addition & 2 deletions src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ export const LASTFM_AUTH_URL =
`&cb=${window.location.protocol}//${window.location.host}/lastfm/callback/`;
export const PATREON_AUTH_URL = `https://www.patreon.com/oauth2/authorize?response_type=code&client_id=${process.env.REACT_APP_PATREON_CLIENT_ID}&redirect_uri=${window.location.protocol}//${process.env.REACT_APP_HOST}/patreon/callback`;
export const OPENSCROBBLER_API_URL = '/api/v2';
export const SETLISTFM_API_URL = '/api/v2/setlistfm.php';
export const CONSIDER_HISTORY_STALE_AFTER = 5 * 60 * 1000; // 5 minutes
export const SETTINGS_DEBOUNCE_PERIOD = 3 * 1000; // 3 seconds
export const SCROBBLING_DEBOUNCE_PERIOD = 1.5 * 1000; // 1.5 seconds
Expand All @@ -17,7 +16,7 @@ export const MAX_RECENT_ALBUMS = 8;
export const DEFAULT_SONG_DURATION = 3 * 60; // ToDo: use this value when skipping time forward after scrobble
// Live music is unpredictable, but usually the band takes a few seconds to breathe / introduce song.
// Adding some leeway to encapsulate this, but it will never be perfect.
export const DEFAULT_CONCERT_SONG_BUFFER = 60;
export const DEFAULT_CONCERT_INTERVAL_DURATION = 2 * 60;

// ToDo: improve this
export type Provider = 'lastfm' | 'discogs' | 'spotify' | 'setlistfm';
Expand Down
2 changes: 1 addition & 1 deletion src/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default function Routes() {
<Route path="/scrobble/user/:username" element={<PrivateRoute using={ScrobbleUserResults} />} />

<Route path="/scrobble/setlist" element={<PrivateRoute using={ScrobbleSetlistSearch} />} />
<Route path="/scrobble/setlist/search/:setlistId" element={<PrivateRoute using={ScrobbleSetlistResult} />} />
<Route path="/scrobble/setlist/search/:query" element={<PrivateRoute using={ScrobbleSetlistResult} />} />
<Route path="/scrobble/setlist/view/:setlistId" element={<PrivateRoute using={ScrobbleSetlistView} />} />

<Route path="/patreon/callback" element={<PrivateRoute using={PatreonCallback} />} />
Expand Down
3 changes: 1 addition & 2 deletions src/domains/home/HomeUser.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Row } from 'reactstrap';
import { faCompactDisc, faPencilAlt, faUserFriends, faList } from '@fortawesome/free-solid-svg-icons';
import { faCompactDisc, faPencilAlt, faUserFriends } from '@fortawesome/free-solid-svg-icons';
import BigHomeButton from './partials/BigHomeButton';
import SocialNetworksBlock from './partials/SocialNetworksBlock';
import WelcomeBlock from './partials/WelcomeBlock';
Expand All @@ -13,7 +13,6 @@ export default function Home() {
<BigHomeButton href="/scrobble/song" icon={faPencilAlt} i18nKey="scrobbleManually" />
<BigHomeButton href="/scrobble/album" icon={faCompactDisc} i18nKey="scrobbleFromAlbum" />
<BigHomeButton href="/scrobble/user" icon={faUserFriends} i18nKey="scrobbleFromOtherUser" />
<BigHomeButton href="/scrobble/setlist" icon={faList} i18nKey="scrobbleFromSetlist" />
</Row>
</div>
<SocialNetworksBlock />
Expand Down
40 changes: 24 additions & 16 deletions src/domains/scrobbleSetlist/ScrobbleSetlistResult.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,48 @@ import { faList } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useParams } from 'react-router-dom';

import { setlistSearch as SetlistFmSearch } from 'utils/clients/setlistfm';
import { searchSetlist } from 'utils/clients/setlistfm';
import { SetlistList } from './partials/SetlistList';
import Paginator from 'components/Paginator';
import Spinner from 'components/Spinner';
import { Trans } from 'react-i18next';

export function ScrobbleSetlistResult() {
const params = useParams();
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [artistName, setArtistName] = useState('');
const [currentPage, setCurrentPage] = useState(1);

// This extracts the search parameter from the URL
useEffect(() => {
setQuery(decodeURIComponent(params?.setlistId || ''));
setArtistName(decodeURIComponent(params?.query || '').toLowerCase());
}, [params]);

const { data } = useQuery({
queryKey: ['setlist', query],
const { data, isLoading } = useQuery({
queryKey: ['setlist', 'search', 'artist', artistName, currentPage],
queryFn: () => {
return SetlistFmSearch(query);
return searchSetlist(artistName, currentPage);
},
enabled: !!query,
enabled: !!artistName,
});

useEffect(() => {
if (data !== undefined) {
navigate(`/scrobble/setlist/view/${data.id}`, { state: data });
}
}, [data, navigate]);

return (
<>
<h2>
<FontAwesomeIcon icon={faList} className="me-2" />
Scrobble Setlist Result
<Trans i18nKey="scrobbleSetlist">Scrobble Setlist</Trans>
</h2>
{isLoading ? (
<Spinner />
) : (
<>
{data?.results && <SetlistList setlists={data.results} query={artistName} />}
{data?.totalPages > 1 && (
<Paginator pageCount={data.totalPages} currentPage={data.page} onPageChange={setCurrentPage} />
)}
</>
)}
</>
);
}
6 changes: 6 additions & 0 deletions src/domains/scrobbleSetlist/ScrobbleSetlistSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,10 @@ describe('`extractSetlistID` helper', () => {
const result = extractSetlistID(text);
expect(result).toEqual('ba97d62');
});

it('fails to get an ID for a random text', () => {
const text = 'Lorem ipsum';
const result = extractSetlistID(text);
expect(result).toEqual(null);
});
});
32 changes: 15 additions & 17 deletions src/domains/scrobbleSetlist/ScrobbleSetlistSearch.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import ReactGA from 'react-ga-neo';

import SearchForm from 'components/SearchForm';
import { PROVIDER_SETLISTFM, PROVIDER_NAME } from 'Constants';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';

export function extractSetlistID(setlistUrl: string) {
const setlistId = setlistUrl.trim().match(/([a-zA-Z0-9]+)(?=\.html)/)[0];
return setlistId;
export function extractSetlistID(setlistUrl: string): string | null {
return setlistUrl.match(/([a-zA-Z0-9]+)(?=\.htm)/)?.[0] ?? null;
}

export function ScrobbleSetlistSearch() {
const navigate = useNavigate();
const validator = (setlistUrl) =>
setlistUrl.trim().length > 0 && setlistUrl.includes('setlist.fm') && setlistUrl.includes('.html');
const validator = (setlistUrl) => setlistUrl.trim().length > 2;
const { t } = useTranslation();

const onSearch = (query) => {
Expand All @@ -23,24 +20,25 @@ export function ScrobbleSetlistSearch() {
});
const setlistId = extractSetlistID(query);

navigate(`/scrobble/setlist/search/${setlistId}`, {
state: {
query,
},
});
if (setlistId) {
navigate(`/scrobble/setlist/view/${setlistId}`);
} else {
navigate(`/scrobble/setlist/search/${query}`, {
state: {
query,
},
});
}
};

return (
<div>
<h1>Scrobble Setlist</h1>
<p>
Please provide the setlist link from{' '}
<a href="https://www.setlist.fm" target="_blank" rel="noopener noreferrer">
Setlilst.fm
</a>
<Trans i18nKey="searchSetlistPrompt">Search by artist name or paste a setlist.fm link</Trans>
</p>
<SearchForm
searchCopy={t('searchOnProvider', { dataProvider: PROVIDER_NAME[PROVIDER_SETLISTFM] })}
searchCopy={t('search')}
onSearch={onSearch}
feedbackMessageKey="invalidSetlistFMUrl"
ariaLabel="Setlist"
Expand Down
44 changes: 31 additions & 13 deletions src/domains/scrobbleSetlist/ScrobbleSetlistView.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,47 @@
import { faHistory, faList } from '@fortawesome/free-solid-svg-icons';
import { useEffect, useState } from 'react';
import { Trans } from 'react-i18next';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { RootState } from 'store';
import { useQuery } from '@tanstack/react-query';

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHistory, faList } from '@fortawesome/free-solid-svg-icons';

import { getSetlistById } from 'utils/clients/setlistfm';

import { ClearHistoryButton } from 'components/ClearHistoryButton';
import EmptyScrobbleListFiller from 'components/EmptyScrobbleListFiller';
import ScrobbleList from 'components/ScrobbleList';
import SetlistViewer from 'domains/scrobbleSetlist/Setlist';
import { Trans } from 'react-i18next';
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { RootState } from 'store';
import { setlistTransformer } from 'utils/clients/setlistfm/transformers.ts/setlist.transformer';
import Spinner from 'components/Spinner';
import SetlistViewer from './partials/SetlistViewer';

export function ScrobbleSetlistView() {
const { state } = useLocation();
const params = useParams();
const [setlistId, setSetlistId] = useState('');
const scrobbles = useSelector((state: RootState) => state.scrobbles.list);
const setlist = setlistTransformer(state);

// This extracts the search parameter from the URL
useEffect(() => {
setSetlistId(decodeURIComponent(params?.setlistId || '').toLowerCase());
}, [params]);

const { data, isLoading } = useQuery({
queryKey: ['setlist', 'view', setlistId],
queryFn: () => {
return getSetlistById(setlistId);
},
enabled: !!setlistId,
});

return (
<>
<h2>
<FontAwesomeIcon icon={faList} className="me-2" />
Scrobble Setlist
<Trans i18nKey="scrobbleSetlist">Scrobble Setlist</Trans>
</h2>
<div className="row mb-5">
<div className="col-md-7 mb-4">
<SetlistViewer setlist={setlist} />
</div>
<div className="col-md-7 mb-4">{isLoading || !data ? <Spinner /> : <SetlistViewer setlist={data} />}</div>
<div className="col-md-5">
<div className="d-flex flex-row justify-content-between">
<h4>
Expand Down
27 changes: 27 additions & 0 deletions src/domains/scrobbleSetlist/partials/EmptySetlistMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useNavigate } from 'react-router-dom';
import { Trans } from 'react-i18next';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBolt, faArrowLeft, faGuitar } from '@fortawesome/free-solid-svg-icons';

export function EmptySetlistMessage() {
const navigate = useNavigate();

const goBack = (e) => {
e.preventDefault();
navigate(-1);
};

return (
<div className="row">
<div className="col-12 text-center mt-4">
<FontAwesomeIcon icon={faBolt} transform="shrink-5 down-2 left-2 rotate-120" mask={faGuitar} size="4x" />
<p className="mt-2">
<Trans i18nKey="emptySetlist">This setlist seems to be empty.</Trans>
</p>
<a href="/scrobble/album" onClick={goBack} className="my-2">
<FontAwesomeIcon icon={faArrowLeft} /> <Trans i18nKey="goBack">Go back</Trans>
</a>
</div>
</div>
);
}
60 changes: 60 additions & 0 deletions src/domains/scrobbleSetlist/partials/SetlistCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { faMapMarkerAlt, faUser } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Card, CardBody } from 'reactstrap';
import type { Setlist } from 'utils/types/setlist';

export function SetlistCard({
setlist,
className = '',
linkArtist = false,
}: {
setlist: Setlist;
className?: string;
linkArtist?: boolean;
}) {
const { t } = useTranslation();
const shortMonth = t('dates.months.short', { returnObjects: true })[setlist.date.getMonth()];

const ArtistInfo = (
<>
<FontAwesomeIcon icon={faUser} className="me-2" />
{setlist.artist}
</>
);

return (
<Card className={className}>
<CardBody className="row no-gutters px-4">
<div className="col-3 calendar-thing text-center bg-dark rounded-1 py-1" style={{ whiteSpace: 'nowrap' }}>
<span className="month text-small lh-1 position-relative text-uppercase" style={{ top: '0.2em' }}>
{shortMonth}
</span>
<span className="day d-block fs-1 lh-1 fw-bold py-0 my-0 text-white">{setlist.date.getDate()}</span>
<small className="year text-small">{setlist.date.getFullYear()}</small>
</div>
<div className="col-9 ps-3">
<h3 className="h4 pt-1 text-truncate text-white">{setlist.venue.name}</h3>
<span className="d-block text-truncate">
{linkArtist ? (
<Link
to={`/scrobble/setlist/search/${encodeURIComponent(setlist.artist)}`}
className="text-decoration-none"
>
{ArtistInfo}
</Link>
) : (
ArtistInfo
)}
</span>
<small>
<FontAwesomeIcon icon={faMapMarkerAlt} className="me-2" />
{setlist.venue.city}
{setlist.venue.state ? `, ${setlist.venue.state}` : ''} <span>({setlist.venue.country})</span>
</small>
</div>
</CardBody>
</Card>
);
}
Loading

0 comments on commit 3e28103

Please sign in to comment.