Skip to content

Commit

Permalink
Support filtering track titles in albums (#237)
Browse files Browse the repository at this point in the history
* Add cleanup pattern from track names in scrobbling operations
* Use context for cleanup pattern and misc changes

---------

Co-authored-by: Enrico Lamperti <[email protected]>
  • Loading branch information
silversonicaxel and elamperti authored Sep 15, 2024
1 parent 6233717 commit 51681d4
Show file tree
Hide file tree
Showing 12 changed files with 353 additions and 20 deletions.
4 changes: 3 additions & 1 deletion public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
"scrobbleSelected": "Scrobble selected",
"findAlbumCopy": "Enter an album or artist name",
"customTimestamp": "Custom timestamp",
"albumTimestampLogicDescription": "Scrobbling tracks \"now\" will scrobble the last track at the current time, backdating the previous ones accordingly (as if you had just finished listening). The \"custom timestamp\" will define the timestamp of the first track and add time to the following tracks from there (i.e. you specify the time you started listening to this album)",
"albumTimestampLogicDescription": "Scrobbling tracks \"now\" will scrobble the last track at the current time, backdating the previous ones accordingly (as if you had just finished listening). The \"custom timestamp\" will define the timestamp of the first track and add time to the following tracks from there (i.e. you specify the time you started listening to this album).",
"albumArtist": "Album artist",
"filter": "Filter",
"albumCleanupPatternDescription": "Some albums tracks are displayed with some repeated text (i.e. Demo, Live, Remastered, ...). These are patterns that could be considered annoying to scrobble, for some users. Set the full pattern (including paranthesis, dashes, ...) that you would like to be removed, from the track names.",
"history": "History",
"yourProfile": "Your profile",
"yourHistory": "Your history",
Expand Down
1 change: 1 addition & 0 deletions public/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"customTimestamp": "Timestamp personalizzato",
"albumTimestampLogicDescription": "Scrobblando le canzoni \"adesso\" scrobblerà l'ultima canzone in questo momento, anticipando relativamente le precedenti (come se avessi appena finito di ascoltarle). Il \"timestamp personalizzato\" definirà il timestamp di ascolto della prima traccia ed agginguerà il relativo tempo alle canzoni successive (come se avessi iniziato ad ascoltare l'album nel timestamp selezionato)",
"albumArtist": "Artista di un Album",
"albumCleanupPatternDescription": "Alcune canzoni di album sono visualizzate con un certo testo ripetuto (ad esempio Demo, Live, Remastered, ...). Questi sono pattern che potrebbero risultare fastidiosi da scrobblare, per alcuni utenti. Imposta il pattern completo (incluse parentesi, trattini, ...) che desideri venga rimosso dai nomi delle canzoni.",
"history": "Storico",
"yourProfile": "Tuo profilo",
"yourHistory": "Tuo storico",
Expand Down
5 changes: 5 additions & 0 deletions src/components/ScrobbleItem.css
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,8 @@
padding-left: 1.15rem;
padding-right: 1.15rem;
}

.scrobbled-item del {
color: var(--bs-secondary-color) !important;
background-color: rgba(255, 32, 0, 0.2);
}
26 changes: 21 additions & 5 deletions src/components/ScrobbleItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { get } from 'lodash-es';
import { enqueueScrobble } from 'store/actions/scrobbleActions';

import { Button, Input, Dropdown, DropdownToggle, DropdownMenu, DropdownItem, FormGroup, Label } from 'reactstrap';
import { useState } from 'react';
import { Fragment, useState } from 'react';
import { LazyLoadImage } from 'react-lazy-load-image-component';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
Expand All @@ -22,6 +22,7 @@ import {
} from '@fortawesome/free-solid-svg-icons';
import { faClock, faCopy } from '@fortawesome/free-regular-svg-icons';
import { getAmznLink } from 'Constants';
import { breakStringUsingPattern, cleanTitleWithPattern } from 'domains/scrobbleAlbum/CleanupContext';

import type { Scrobble } from 'utils/types/scrobble';

Expand All @@ -31,6 +32,7 @@ import { formatDuration, formatScrobbleTimestamp } from 'utils/datetime';

interface ScrobbleItemProps {
scrobble: Scrobble;
cleanupPattern?: RegExp;
compact?: boolean;
hideArtist?: boolean;
muteArtist?: boolean;
Expand All @@ -45,6 +47,7 @@ interface ScrobbleItemProps {

export default function ScrobbleItem({
scrobble,
cleanupPattern,
compact = false,
hideArtist = false,
muteArtist = false,
Expand Down Expand Up @@ -83,6 +86,7 @@ export default function ScrobbleItem({
enqueueScrobble(dispatch)([
{
...scrobble,
title: cleanTitleWithPattern(scrobble.title, cleanupPattern),
timestamp: useOriginalTimestamp ? scrobble.timestamp : new Date(),
},
]);
Expand All @@ -100,6 +104,13 @@ export default function ScrobbleItem({
return word.charAt(0).toUpperCase() + word.substr(1).toLowerCase();
});
};

const strikethroughMatch = (text: string, pattern?: RegExp) => {
return breakStringUsingPattern(text, pattern).map(({ value, isMatch }, index) => (
<Fragment key={index}>{!isMatch ? value : <del>{value}</del>}</Fragment>
));
};

let albumArt;
let errorMessage;
let rightSideContent;
Expand Down Expand Up @@ -172,18 +183,23 @@ export default function ScrobbleItem({
</small>
);

const formattedTitle = strikethroughMatch(properCase(scrobble.title, true), cleanupPattern);
if (!hideArtist) {
if (muteArtist) {
songFullTitle = (
<>
{properCase(scrobble.title, true)} <span className="text-muted">{properCase(scrobble.artist)}</span>
{formattedTitle} <span className="text-muted">{properCase(scrobble.artist)}</span>
</>
);
} else {
songFullTitle = `${properCase(scrobble.artist)} - ${properCase(scrobble.title, true)}`;
songFullTitle = (
<>
{properCase(scrobble.artist)} - {formattedTitle}
</>
);
}
} else {
songFullTitle = properCase(scrobble.title, true);
songFullTitle = formattedTitle;
}

const scrobbleItemInputId = `ScrobbleItem-checkbox-${scrobble.uuid}`;
Expand All @@ -201,7 +217,7 @@ export default function ScrobbleItem({
// FULL view
songInfo = (
<>
<span className="song">{songFullTitle}</span>
<span className="song flex-grow-1 pe-2 truncate">{songFullTitle}</span>
<Label className="d-flex mb-0" htmlFor={scrobbleItemInputId}>
<small className="text-muted flex-grow-1 truncate album">
{scrobble.album && (
Expand Down
5 changes: 4 additions & 1 deletion src/components/ScrobbleList.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useContext } from 'react';
import { useContext, useEffect } from 'react';

Check warning on line 1 in src/components/ScrobbleList.tsx

View workflow job for this annotation

GitHub Actions / Setup and quick checks

'useEffect' is defined but never used

import ScrobbleItem from 'components/ScrobbleItem';
import Spinner from 'components/Spinner';
import { ScrobbleCloneContext } from 'domains/scrobbleSong/ScrobbleSong';

import type { ReactNode } from 'react';
import { CleanupPatternContext } from 'domains/scrobbleAlbum/CleanupContext';

interface ScrobbleListProps {
analyticsEventForScrobbles?: string;
Expand All @@ -30,6 +31,7 @@ export default function ScrobbleList({
scrobbles = [],
}: ScrobbleListProps) {
const { cloneFn, setCloneFn } = useContext(ScrobbleCloneContext);
const cleanupCtx = useContext(CleanupPatternContext); // this may be undefined
let albumHasVariousArtists = !isAlbum;

if (loading) {
Expand All @@ -53,6 +55,7 @@ export default function ScrobbleList({
<ScrobbleItem
scrobble={scrobble}
analyticsEvent={analyticsEventForScrobbles}
cleanupPattern={cleanupCtx?.cleanupPattern}
cloneScrobbleTo={setCloneFn ? cloneFn : undefined}
compact={compact}
noMenu={noMenu}
Expand Down
125 changes: 125 additions & 0 deletions src/domains/scrobbleAlbum/CleanupContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { cleanTitleWithPattern, breakStringUsingPattern, strToCleanupPattern } from './CleanupContext';

describe('cleanTitleWithPattern', () => {
it('removes pattern from title', () => {
const title = 'Song Title live';
const pattern = strToCleanupPattern('LIVE');
expect(cleanTitleWithPattern(title, pattern)).toBe('Song Title');
});

it('returns the original title if pattern is not found', () => {
const title = 'Song Title (live)';
const pattern = strToCleanupPattern('remix');
expect(cleanTitleWithPattern(title, pattern)).toBe('Song Title (live)');
});

it('returns the original title if pattern is null', () => {
const title = 'Song Title (2024)';
expect(cleanTitleWithPattern(title, null)).toBe('Song Title (2024)');
});

it('skips patterns of less than 3 characters', () => {
const title = 'Song Title (20)';
const pattern = strToCleanupPattern('20');
expect(cleanTitleWithPattern(title, pattern)).toBe('Song Title (20)');
});

it('removes trailing dashes', () => {
const title = 'Song Title - Live';
const pattern = strToCleanupPattern('live');
expect(cleanTitleWithPattern(title, pattern)).toBe('Song Title');
});

it('removes empty parenthesis', () => {
const title = 'Song Title (remix)';
const pattern = strToCleanupPattern('remix');
expect(cleanTitleWithPattern(title, pattern)).toBe('Song Title');
});

it('removes empty brackets', () => {
const title = 'Song Title [fOoB4r]';
const pattern = strToCleanupPattern('fOoB4r');
expect(cleanTitleWithPattern(title, pattern)).toBe('Song Title');
});

it("doesn't match partial end of words", () => {
const title = 'Song Title remix';
const pattern = strToCleanupPattern('mix');
expect(cleanTitleWithPattern(title, pattern)).toBe('Song Title remix');
});

it("doesn't match partial beginning of words", () => {
const title = 'Love (Sweet Love) (Dopamine Remix)';
const pattern = strToCleanupPattern('dopa');
expect(cleanTitleWithPattern(title, pattern)).toBe(title);
});

it('auto-closes parenthesis', () => {
const title = 'Song Title (remix) [1984]';
const pattern = strToCleanupPattern('(remix');
expect(cleanTitleWithPattern(title, pattern)).toBe('Song Title [1984]');
});

it('auto-closes brackets', () => {
const title = 'Song Title (remix) [1984]';
const pattern = strToCleanupPattern('[1984');
expect(cleanTitleWithPattern(title, pattern)).toBe('Song Title (remix)');
});

it('matches words starting with symbols', () => {
const title = 'Song Title $remix';
const pattern = strToCleanupPattern('$remix');
expect(cleanTitleWithPattern(title, pattern)).toBe('Song Title');
});

it('supports several words in the pattern', () => {
const title = "I can't quit you baby - Live in paris, 1969";
const pattern = strToCleanupPattern('live in paris, 1969');
expect(cleanTitleWithPattern(title, pattern)).toBe("I can't quit you baby");
});
});

describe('breakStringUsingPattern', () => {
it('breaks down the string into parts matching the pattern', () => {
const str = 'Song Title (remix) (live) (2024)';
const pattern = /\(remix\)|\(live\)|\(2024\)/gi;
const result = breakStringUsingPattern(str, pattern);
expect(result).toEqual([
{ value: 'Song Title', isMatch: false },
{ value: ' (remix)', isMatch: true },
{ value: ' (live)', isMatch: true },
{ value: ' (2024)', isMatch: true },
]);
});

it('marks extra spaces', () => {
const str = 'Song Title (remix) - More';
const pattern = /\(remix\)/gi;
const result = breakStringUsingPattern(str, pattern);
expect(result).toEqual([
{ value: 'Song Title', isMatch: false },
{ value: ' (remix)', isMatch: true },
{ value: ' - More', isMatch: false },
]);
});

it('returns the whole string as non-matching if pattern is not found', () => {
const str = 'Song Title (remix)';
const pattern = /\(live\)/gi;
const result = breakStringUsingPattern(str, pattern);
expect(result).toEqual([{ value: 'Song Title (remix)', isMatch: false }]);
});

it('returns the whole string as non-matching if pattern is null', () => {
const str = 'Song Title (2024)';
const result = breakStringUsingPattern(str, null);
expect(result).toEqual([{ value: 'Song Title (2024)', isMatch: false }]);
});

it('returns an empty array if the string is empty', () => {
const str = '';
const pattern = /\(remix\)/gi;
const result = breakStringUsingPattern(str, pattern);
expect(result).toEqual([]);
});
});
123 changes: 123 additions & 0 deletions src/domains/scrobbleAlbum/CleanupContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { createContext } from 'react';
import { escapeRegExp } from 'lodash-es';

export type CleanupContext = {
// this type represents the content of the context, it's a regexp value (cleanupPattern) and the corresponidng state setter (setCleanupPattern)
cleanupPattern: RegExp | null;
setCleanupPattern: (pattern?: RegExp) => void;
};

type BreakStringResult = {
value: string;
isMatch: boolean;
};

export const CleanupPatternContext = createContext<CleanupContext>({
cleanupPattern: null,
setCleanupPattern: () => {},
});

export function cleanTitleWithPattern(title: string, pattern: RegExp): string {
if (!pattern) return title;

return breakStringUsingPattern(title, pattern)
.filter(({ isMatch }) => !isMatch)
.reduce((acc, { value }) => acc + value, '')
.trim();
}

/**
* Converts a string into a regular expression, to be used as cleanup pattern
* @param str string to be converted to pattern
* @returns regular expression using the given string
*/
export function strToCleanupPattern(str: string): RegExp {
if (!str || str.trim().length < 3) return;
const startsWithWord = str.match(/^\w/);
const prefix = startsWithWord ? '\\s?\\b' : ''; // disallow partial words

let postfix = '';
if (startsWithWord) {
// disallow partial words
postfix = '\\s?\\b';
} else if (str.match(/^[([]/)) {
// auto-close parenthesis and brackets
postfix = '[\\)\\]]?';
}
return new RegExp(`${prefix}${escapeRegExp(str)}${postfix}`, 'ig');
}

/**
* Breaks down the given string into pieces that match (or don't match) the given pattern
* @param str string to break down into parts
* @param pattern pattern to use to break down the string
*/
export function breakStringUsingPattern(str: string, pattern: RegExp): BreakStringResult[] {
if (!str) return [];

const __stringCrumble = (value: string, isMatch: boolean): BreakStringResult => ({
value,
isMatch,
});

if (!pattern) return [__stringCrumble(str, false)];

const matches = str.matchAll(pattern);

if (!matches) return [__stringCrumble(str, false)];

const crumbles = [] as BreakStringResult[];
const splitStr = str.split(pattern);

splitStr.forEach((part, index) => {
// This block checks interactions between valid parts for invalid combinations
// (extra spaces, empty parenthesis/brackets, more than one dash)
if (index > 0) {
let previous = splitStr[index - 1];

if (previous.match(/ +$/) && part.match(/^(\s|$)/)) {
// modify the previous crumble to remove the space
previous = previous.trimEnd();
crumbles[crumbles.length - 2].value = previous;
// and add the space to the corresponding match
crumbles[crumbles.length - 1].value = ' ' + crumbles[crumbles.length - 1].value;
}

if (previous.endsWith('(') && part.startsWith(')')) {
// modify the previous crumble to remove the empty parenthesis
crumbles[crumbles.length - 2].value = previous.slice(0, -1);
// add it to the corresponding match
crumbles[crumbles.length - 1].value = `(${crumbles[crumbles.length - 1].value})`;
// and remove it from the part we're currently processing
part = part.slice(1);
}

if (previous.endsWith('[') && part.startsWith(']')) {
// modify the previous crumble to remove the empty brackets
crumbles[crumbles.length - 2].value = previous.slice(0, -1);
// add it to the corresponding match
crumbles[crumbles.length - 1].value = `[${crumbles[crumbles.length - 1].value}]`;
// and remove it from the part we're currently processing
part = part.slice(1);
}

if (previous.match(/[-]$/) && (!part || part.match(/^\s*[-]/))) {
const removedChar = previous.at(-1);
// modify the previous crumble to remove the extra dash
crumbles[crumbles.length - 2].value = previous.slice(0, -1);
// and add it to the corresponding match
crumbles[crumbles.length - 1].value = removedChar + crumbles[crumbles.length - 1].value;
}
}

// Then we add the current part to the crumbles array
crumbles.push(__stringCrumble(part, false));

// And its corresponding match, if there is one
const match = matches.next();
if (match.done) return;
crumbles.push(__stringCrumble(match.value[0], true));
});

return crumbles.filter((crumble) => crumble.value);
}
Loading

0 comments on commit 51681d4

Please sign in to comment.