Skip to content
Merged
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
3 changes: 2 additions & 1 deletion apps/localtoolbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2565,7 +2565,8 @@ takes one off again.
badges, up to three on the row and a **+n** for the rest. Each badge opens
that page, behind the popup or in front depending on the **background
tabs** switch the Manager also has. An artist SoundCloud had no public
profile for, or whose request failed, says **no public profile**.
profile for says **no public profile**. A lookup that fails says **lookup
failed** and leaves what the row knew.
4. The chevron opens the row into everything else, one field a line, each a
button that copies it: the SoundCloud profile and every link as an address
(with an arrow to open it), SoundCloud's own name for the account where the
Expand Down
31 changes: 22 additions & 9 deletions apps/localtoolbox/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3074,12 +3074,13 @@
};

// The panel's question: `profiles` for the paths that are public profiles,
// `failed` for the ones that are not (or could not be asked about), and
// `error` ('' | 'no-client-id' | 'unauthorized'). Progress goes out as it
// comes, an artist at a time, so the rows fill in rather than wait.
// `missing` for the ones SoundCloud says are not, `failed` for the ones that
// could not be asked about, and `error` ('' | 'no-client-id' | 'unauthorized').
// Progress goes out as it comes, an artist at a time, so the rows fill in
// rather than wait.
/** @param permalinks the panel's list, checked path by path. */
const collectArtistProfiles = async (permalinks: Json | undefined): Promise<ProfilesAnswer> => {
const result: ProfilesAnswer = { ok: true, profiles: [], failed: [], error: '' };
const result: ProfilesAnswer = { ok: true, profiles: [], missing: [], failed: [], error: '' };
const wanted = [...new Set(listOf(permalinks).filter(isArtistPath))];

if (wanted.length === 0) return result;
Expand All @@ -3095,13 +3096,14 @@
let unauthorized = false;
let done = 0;

const report = (profiles: ArtistProfile[], failed: string[]) => {
const report = (profiles: ArtistProfile[], missing: string[], failed: string[]) => {
chrome.runtime
.sendMessage({
type: 'ARTIST_PROFILES_PROGRESS',
done,
total: wanted.length,
profiles,
missing,
failed
} satisfies ProfilesProgress)
.catch(() => {});
Expand All @@ -3112,24 +3114,35 @@
API_CONCURRENCY,
async (permalink) => {
let profile = null;
let asked = true;

try {
profile = await fetchProfile(permalink, clientId);
} catch (e) {
// 401 means the client id is no longer accepted, which says nothing
// about this artist and stops the rest; anything else is this one.
// about this artist and stops the rest. A 404 is SoundCloud's answer
// that nobody is there; anything else leaves the question open.
if (e instanceof ApiError && e.status === 401) {
unauthorized = true;

return;
}

asked = e instanceof ApiError && e.status === 404;
}

done += 1;

if (profile) result.profiles.push(profile);
else result.failed.push(permalink);
report(profile ? [profile] : [], profile ? [] : [permalink]);
if (profile) {
result.profiles.push(profile);
report([profile], [], []);
} else if (asked) {
result.missing.push(permalink);
report([], [permalink], []);
} else {
result.failed.push(permalink);
report([], [], [permalink]);
}
},
() => !unauthorized
);
Expand Down
2 changes: 1 addition & 1 deletion apps/localtoolbox/docs/artists.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ page and press **look up**.
## The list

Each row has the artist's picture, name, where they are, how many follow them, and their links as
lettered badges. A badge opens that page. An artist with no public profile says so, and so does one whose lookup failed.
lettered badges. A badge opens that page. An artist with no public profile says so. A lookup that fails says so too, and the row keeps what it had.

The chevron opens a row into every link as an address, the bio, the place, the follower count and
the tracks that brought the artist in. Click any of them to copy it. Below are the same search
Expand Down
5 changes: 4 additions & 1 deletion apps/localtoolbox/src/lib/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,9 @@ export interface DetailsAnswer extends TracksAnswer {
export interface ProfilesAnswer {
ok: true;
profiles: ArtistProfile[];
/** The paths with no public profile behind them, or that could not be asked about. */
/** The paths SoundCloud says have no public profile behind them. */
missing: string[];
/** The paths that could not be asked about, which says nothing about them. */
failed: string[];
error: LookupError;
}
Expand Down Expand Up @@ -386,6 +388,7 @@ export interface ProfilesProgress {
done: number;
total: number;
profiles: ArtistProfile[];
missing: string[];
failed: string[];
}

Expand Down
33 changes: 20 additions & 13 deletions apps/localtoolbox/src/tools/artists/Panel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,9 @@
/** The paths the running lookup covers, so their rows can say so. */
let checkingIds: Set<string> = $state(new Set());

/** Who the last lookup of them couldn't ask about. Not stored: it's about that lookup, not the artist. */
let failedIds: Set<string> = $state(new Set());

/** What the last add or lookup came to, when it is worth leaving on screen. */
let note = $state<{ tone: 'bad' | 'info'; text: string } | null>(null);

Expand All @@ -240,26 +243,28 @@

if (!lookingUp) return;
progress = message;
take(answersIn(message));
take(message);
});
} catch (_e) {
// No runtime to listen on: the lookup just finishes without a count.
}

/**
* A lookup's answers as the store takes them: profiles, and failures. `as const` keeps
* `unavailable: true` from widening to a boolean inside the map.
* Folds a lookup's answers into the list. A profile or SoundCloud saying there is none goes to
* the store. A lookup that failed says nothing about the artist, so what the row holds stays,
* and the row says so until it's asked again.
* @param answer a progress message, or the lookup's whole answer
*/
const answersIn = ({ profiles, failed }: Pick<ProfilesAnswer, 'profiles' | 'failed'>) => [
...(Array.isArray(profiles) ? profiles : []),
...(Array.isArray(failed) ? failed : []).map((permalink) => ({ permalink, unavailable: true as const })),
];
function take({ profiles, missing, failed }: Partial<Pick<ProfilesAnswer, 'profiles' | 'missing' | 'failed'>>) {
const answers: ProfileAnswer[] = [
...(profiles ?? []),
...(missing ?? []).map((permalink) => ({ permalink, unavailable: true as const })),
];

function take(answers: ProfileAnswer[]) {
artists.learn(answers);
const answered = new Set(answers.map((answer) => answer?.permalink));
const answered = new Set([...answers.map((answer) => answer.permalink), ...(failed ?? [])]);
checkingIds = new Set([...checkingIds].filter((permalink) => !answered.has(permalink)));
failedIds = new Set([...failedIds, ...(failed ?? [])]);
}

const said = ({ added, refreshed }: { added: number; refreshed: number }, from: string) => {
Expand All @@ -286,14 +291,15 @@
lookingUp = true;
progress = null;
checkingIds = new Set(permalinks);
failedIds = new Set([...failedIds].filter((permalink) => !checkingIds.has(permalink)));
const result = await lookupProfiles(permalinks);

if (result.status) {
note = { tone: 'bad', text: `couldn’t look up their socials. ${result.status}` };
} else {
// The whole answer, answers already streamed included: the same
// profile twice is the same profile.
take(answersIn(result.data!));
take(result.data!);
const why = LOOKUP_ERRORS[result.data!.error];

// Said once it is over, through the toasts' live region: the bar above
Expand Down Expand Up @@ -673,6 +679,7 @@
{@const name = nameOf(entry)}
{@const links = linksOf(entry.profile)}
{@const checking = checkingIds.has(entry.permalink)}
{@const failed = failedIds.has(entry.permalink)}
{@const avatar = hasProfile(entry) ? entry.profile.avatarUrl : ''}
<li
data-permalink={entry.permalink}
Expand Down Expand Up @@ -703,12 +710,12 @@
{/if}
</p>
<p
class="truncate text-caption {isUnavailable(entry)
class="truncate text-caption {isUnavailable(entry) || failed
? 'text-fg-subtle italic'
: 'text-fg-muted'}"
title={profileLine(entry, { checking })}
title={profileLine(entry, { checking, failed })}
>
{profileLine(entry, { checking })}
{profileLine(entry, { checking, failed })}
</p>
</div>

Expand Down
2 changes: 1 addition & 1 deletion apps/localtoolbox/src/tools/artists/artists.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ class Artists {
}

/**
* Takes in what a lookup said: a profile per artist, or that there is
* Takes in what a lookup said: a profile per artist, or that SoundCloud has
* none (`{ permalink, unavailable: true }`). Answers for artists not on
* the list are dropped: they may have been taken off while the lookup
* ran. Each is stamped with the time it arrived.
Expand Down
7 changes: 5 additions & 2 deletions apps/localtoolbox/src/tools/artists/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,11 +376,14 @@ export const placeOf = (profile?: ProfileEntry | null) => [profile?.city, profil
* followers, how many links), or, when nothing is, why: nobody has asked, or
* SoundCloud had no profile for the path.
*
* @param options `checking` while a lookup is asking about it
* @param options `checking` while a lookup is asking about it, `failed` when
* the last lookup couldn't, whatever the row already holds
*/
export function profileLine(entry: ArtistEntry, { checking = false } = {}) {
export function profileLine(entry: ArtistEntry, { checking = false, failed = false } = {}) {
if (checking) return 'looking up…';

if (failed) return 'lookup failed';

if (isUnchecked(entry)) return 'not looked up';

if (!hasProfile(entry)) return 'no public profile';
Expand Down
45 changes: 38 additions & 7 deletions apps/localtoolbox/test/content/artists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe('before asking', () => {

for (const permalinks of [undefined, [], 'nope', ['/a/b', 7, null, 'bicep', '']]) {
// @ts-expect-error -- what an older or broken panel might send: no list, a string, non-paths
expect(await profilesFor(permalinks)).toEqual({ ok: true, profiles: [], failed: [], error: '' });
expect(await profilesFor(permalinks)).toEqual({ ok: true, profiles: [], missing: [], failed: [], error: '' });
}

expect(fn).not.toHaveBeenCalled();
Expand All @@ -94,7 +94,13 @@ describe('before asking', () => {
render('<p>nothing</p>', '/stream');
const { fn } = mockFetch();
await loadContentScript();
expect(await profilesFor(['/bicep'])).toEqual({ ok: true, profiles: [], failed: [], error: 'no-client-id' });
expect(await profilesFor(['/bicep'])).toEqual({
ok: true,
profiles: [],
missing: [],
failed: [],
error: 'no-client-id'
});
expect(fn).not.toHaveBeenCalled();
});
});
Expand All @@ -118,6 +124,7 @@ describe('one artist', () => {
expect(response).toEqual({
ok: true,
error: '',
missing: [],
failed: [],
profiles: [
{
Expand Down Expand Up @@ -245,7 +252,7 @@ describe('one artist', () => {
await loadContentScript();
const response = await profilesFor(['/a-track', '/no-id', '/nobody']);
expect(response.profiles).toEqual([]);
expect(response.failed.sort()).toEqual(['/a-track', '/no-id', '/nobody']);
expect(response.missing.sort()).toEqual(['/a-track', '/no-id', '/nobody']);
expect(response.error).toBe('');
});
});
Expand Down Expand Up @@ -290,7 +297,7 @@ describe('several artists', () => {
expect(progress.every((message) => message.failed.length === 0)).toBe(true);
});

it('reports a failure as one too', async () => {
it('reports a path SoundCloud has nobody behind as one too', async () => {
page();
api({});
await loadContentScript();
Expand All @@ -301,7 +308,31 @@ describe('several artists', () => {
done: 1,
total: 1,
profiles: [],
failed: ['/nobody']
missing: ['/nobody'],
failed: []
});
});

it('tells a request that failed from an answer that there is nobody', async () => {
page();
mockFetch(() => jsonResponse({ error: 'unavailable' }, 503));
await loadContentScript();
chrome.runtime.sendMessage.mockClear();

expect(await profilesFor(['/bicep'])).toEqual({
ok: true,
profiles: [],
missing: [],
failed: ['/bicep'],
error: ''
});
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
type: 'ARTIST_PROFILES_PROGRESS',
done: 1,
total: 1,
profiles: [],
missing: [],
failed: ['/bicep']
});
});

Expand Down Expand Up @@ -339,7 +370,7 @@ describe('when SoundCloud rejects the client id', () => {
const { fn } = mockFetch(() => jsonResponse({ error: 'unauthorized' }, 401));
await loadContentScript();
const response = await profilesFor(['/a', '/b', '/c', '/d', '/e', '/f', '/g', '/h']);
expect(response).toEqual({ ok: true, profiles: [], failed: [], error: 'unauthorized' });
expect(response).toEqual({ ok: true, profiles: [], missing: [], failed: [], error: 'unauthorized' });
// The four in the air, and not the rest.
expect(fn.mock.calls.length).toBeLessThanOrEqual(4);
});
Expand All @@ -351,7 +382,7 @@ describe('when SoundCloud rejects the client id', () => {
);
await loadContentScript();
const response = await profilesFor(['/bicep']);
expect(response).toEqual({ ok: true, profiles: [], failed: [], error: 'unauthorized' });
expect(response).toEqual({ ok: true, profiles: [], missing: [], failed: [], error: 'unauthorized' });
});

it('backs off and retries a 429 before giving up', async () => {
Expand Down
43 changes: 41 additions & 2 deletions apps/localtoolbox/test/panels/artists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,15 @@ const profile = (permalink: string, extra: Partial<ArtistProfile> = {}) => ({
...extra
});

const answer = (profiles: ArtistProfile[] = [], failed: string[] = [], error: LookupError = ''): ProfilesAnswer => ({
const answer = (
profiles: ArtistProfile[] = [],
missing: string[] = [],
error: LookupError = '',
failed: string[] = []
): ProfilesAnswer => ({
ok: true,
profiles,
missing,
failed,
error
});
Expand Down Expand Up @@ -238,7 +244,7 @@ describe('adding from the tab', () => {
await load();
await addTab();

await chrome.testSendMessage({ type: 'ARTIST_PROFILES_PROGRESS', done: 1, total: 2, failed: ['/fourtet'] });
await chrome.testSendMessage({ type: 'ARTIST_PROFILES_PROGRESS', done: 1, total: 2, missing: ['/fourtet'] });
await settle();
expect(rowText()).toEqual(['Bicep looking up…', 'Four Tet no public profile']);

Expand Down Expand Up @@ -609,6 +615,39 @@ describe('a row opened out', () => {
expect(rowText()[0]).toMatch(/Derry, GB/);
});

it('keeps the profile when looking it up again fails, and says so', async () => {
chromeAnswering({ storage: { artists: [MID] }, lookup: answer([], [], '', ['/mid']) });
await load();
const before = stored()[0].profile;
await click(chevron('Mid'));
await click(labelled('look up Mid again', HTMLButtonElement));

expect(stored()[0].profile).toEqual(before);
expect(rowText()[0]).toMatch(/lookup failed/);
expect(rowText()[0]).not.toMatch(/no public profile/);
});

it('clears the failure once a lookup works', async () => {
const mock = chromeAnswering({ storage: { artists: [MID] }, lookup: answer([], [], '', ['/mid']) });
await load();
await click(chevron('Mid'));
await click(labelled('look up Mid again', HTMLButtonElement));
expect(rowText()[0]).toMatch(/lookup failed/);

mock.tabs.sendMessage.mockImplementation(async () => answer([profile('/mid', { city: 'Derry' })]));
await click(labelled('look up Mid again', HTMLButtonElement));
expect(rowText()[0]).toMatch(/Derry, GB/);
});

it('leaves a new row to be looked up when its lookup fails, rather than calling it private', async () => {
chromeAnswering({ lookup: answer([profile('/bicep')], [], '', ['/fourtet']) });
await load();
await addTab();
await settle();
expect(rowText()[1]).toMatch(/Four Tet lookup failed/);
expect(stored().find((one) => one.permalink === '/fourtet')!.profile).toBe(null);
});

it('is off to look up again without a SoundCloud tab', async () => {
chromeAnswering({ storage: { artists: [MID] }, tab: { id: 1, url: 'https://example.com/' } });
await load();
Expand Down
Loading