Merge remote-tracking branch 'origin/fix/wanted-without-client' into integration/small-fixes
This commit is contained in:
@@ -310,15 +310,35 @@ func (r *Reconciler) run(ctx context.Context, force bool) (Summary, error) {
|
|||||||
|
|
||||||
summary.Synced = r.syncExternalLists(ctx)
|
summary.Synced = r.syncExternalLists(ctx)
|
||||||
|
|
||||||
attempted, started, err := r.attemptDue(ctx, force)
|
// Nothing is searched for when there is nothing to search with, and
|
||||||
if err != nil {
|
// the point is what that *does not* do to the list.
|
||||||
return summary, err
|
//
|
||||||
|
// Attempting anyway is not merely wasted work: every request comes
|
||||||
|
// back "no download clients are enabled", which RecordAttempt writes
|
||||||
|
// down as an attempt and schedules a retry for -- so a user who has
|
||||||
|
// deliberately built a wanted list with no client watched their
|
||||||
|
// requests accrue failures and announce "next check in 6 hours"
|
||||||
|
// about a check that cannot happen. Wanting something without a way
|
||||||
|
// to fetch it is a supported thing to do; being told it is being
|
||||||
|
// looked for is a lie.
|
||||||
|
//
|
||||||
|
// Everything above this line still runs: an artist subscription
|
||||||
|
// still expands, and a request the user satisfied by some other
|
||||||
|
// route -- ripped, bought, copied in -- is still retired, because
|
||||||
|
// neither needs a provider.
|
||||||
|
summary.NoProviders = len(r.manager.enabledProviders()) == 0
|
||||||
|
|
||||||
|
if !summary.NoProviders {
|
||||||
|
attempted, started, err := r.attemptDue(ctx, force)
|
||||||
|
if err != nil {
|
||||||
|
return summary, err
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Attempted = attempted
|
||||||
|
summary.Started = started
|
||||||
}
|
}
|
||||||
|
|
||||||
summary.Attempted = attempted
|
|
||||||
summary.Started = started
|
|
||||||
summary.Waiting = r.countWaiting(ctx)
|
summary.Waiting = r.countWaiting(ctx)
|
||||||
summary.NoProviders = len(r.manager.enabledProviders()) == 0
|
|
||||||
|
|
||||||
r.logger.Info(
|
r.logger.Info(
|
||||||
"reconciled request list",
|
"reconciled request list",
|
||||||
|
|||||||
@@ -453,6 +453,15 @@ func TestReconcileRespectsBatchSize(t *testing.T) {
|
|||||||
f := newReconcileFixture(t)
|
f := newReconcileFixture(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// A client that searches and finds nothing. The batch size is about
|
||||||
|
// how many requests one pass *searches for*, which only means
|
||||||
|
// anything when there is something to search with -- a pass with no
|
||||||
|
// provider now attempts nothing at all, deliberately.
|
||||||
|
f.manager.installProvider(
|
||||||
|
Config{ID: 1, Priority: 50},
|
||||||
|
NewFakeProvider(1, "finds-nothing", Caps{CanSearch: true}),
|
||||||
|
)
|
||||||
|
|
||||||
f.reconciler.SetBatch(2)
|
f.reconciler.SetBatch(2)
|
||||||
|
|
||||||
for _, mbid := range []string{"rg-1", "rg-2", "rg-3", "rg-4"} {
|
for _, mbid := range []string{"rg-1", "rg-2", "rg-3", "rg-4"} {
|
||||||
@@ -593,3 +602,72 @@ func TestSummaryReportsNoProviders(t *testing.T) {
|
|||||||
t.Error("summary did not report that no download client is enabled")
|
t.Error("summary did not report that no download client is enabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ...and it does not search, which is the part the user sees.
|
||||||
|
//
|
||||||
|
// Attempting with no provider fails every request with "no download
|
||||||
|
// clients are enabled", and RecordAttempt writes that down as an
|
||||||
|
// attempt and schedules a retry -- so a wanted list built deliberately
|
||||||
|
// without a client accrued failures and announced "next check in 6
|
||||||
|
// hours" about a check that cannot happen. Wanting something with no
|
||||||
|
// way to fetch it is supported; being told it is being looked for is
|
||||||
|
// a lie.
|
||||||
|
func TestNoProvidersMeansNoAttempt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newReconcileFixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id, err := f.store.AddRequest(ctx, Request{
|
||||||
|
MBID: "rg-1",
|
||||||
|
Entity: EntityReleaseGroup,
|
||||||
|
LibraryID: 1,
|
||||||
|
Title: "OK Computer",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddRequest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
|
||||||
|
|
||||||
|
summary, err := f.reconciler.RunNow(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunNow: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary.Attempted != 0 {
|
||||||
|
t.Errorf("attempted %d requests with no client to search with, want 0",
|
||||||
|
summary.Attempted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list still knows what is on it: "nothing happened" has to be
|
||||||
|
// reportable as "nothing was searched for, of the one thing you
|
||||||
|
// want" rather than as silence.
|
||||||
|
if summary.Waiting != 1 {
|
||||||
|
t.Errorf("summary reported %d waiting, want 1", summary.Waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := f.store.GetRequest(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRequest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Attempts != 0 {
|
||||||
|
t.Errorf("attempts = %d, want 0: a pass that could not search did not",
|
||||||
|
req.Attempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.LastError != "" {
|
||||||
|
t.Errorf("lastError = %q, want empty: the request did not fail, it "+
|
||||||
|
"was never tried", req.LastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new request is due immediately (next_try_at is set to now on
|
||||||
|
// insert), so the fault is not the presence of a time -- it is a
|
||||||
|
// time pushed into the future by a failed attempt, which is what the
|
||||||
|
// UI renders as "next check in 6 hours".
|
||||||
|
if req.NextTryAt.After(time.Now().Add(time.Minute)) {
|
||||||
|
t.Errorf("next try scheduled for %v: a check that cannot happen was "+
|
||||||
|
"put on the clock", req.NextTryAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -512,7 +512,9 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
|
|||||||
${request.artist ? `${request.artist} — ` : ''}${request.title ||
|
${request.artist ? `${request.artist} — ` : ''}${request.title ||
|
||||||
request.mbid}
|
request.mbid}
|
||||||
</div>
|
</div>
|
||||||
<div class="detail">${requestDetail(request, this.nowMs)}</div>
|
<div class="detail">
|
||||||
|
${requestDetail(request, this.nowMs, this.canDownload)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
${request.state === 'satisfied'
|
${request.state === 'satisfied'
|
||||||
@@ -706,10 +708,20 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
|
|||||||
* looked for rather than as an error, because that is what it is — the
|
* looked for rather than as an error, because that is what it is — the
|
||||||
* retry is already scheduled and there is nothing for the user to do.
|
* retry is already scheduled and there is nothing for the user to do.
|
||||||
*/
|
*/
|
||||||
function requestDetail(request: Request, nowMs: number): string {
|
function requestDetail(
|
||||||
|
request: Request,
|
||||||
|
nowMs: number,
|
||||||
|
canDownload: boolean,
|
||||||
|
): string {
|
||||||
if (request.state === 'satisfied') return 'In your library';
|
if (request.state === 'satisfied') return 'In your library';
|
||||||
if (request.state === 'paused') return 'Paused — not being looked for';
|
if (request.state === 'paused') return 'Paused — not being looked for';
|
||||||
|
|
||||||
|
// With no client there is no search and no retry clock — the
|
||||||
|
// backend stopped scheduling one — so a row must not imply either.
|
||||||
|
// "Queued" and "next check in 6 hours" are both promises nothing is
|
||||||
|
// in a position to keep.
|
||||||
|
if (!canDownload) return 'On your list — no download client to search with';
|
||||||
|
|
||||||
if (request.attempts === 0) return 'Queued — not searched for yet';
|
if (request.attempts === 0) return 'Queued — not searched for yet';
|
||||||
|
|
||||||
const tries = `Searched ${request.attempts} time${request.attempts === 1 ? '' : 's'}`;
|
const tries = `Searched ${request.attempts} time${request.attempts === 1 ? '' : 's'}`;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* What a wanted list says when there is nothing to search with.
|
||||||
|
*
|
||||||
|
* Wanting something without a download client is a supported thing to
|
||||||
|
* do — the list is kept, and it starts moving when a client is added.
|
||||||
|
* What was not supported was the app *claiming to be looking*: every
|
||||||
|
* pass attempted each request, failed it with "no download clients are
|
||||||
|
* enabled", recorded that as an attempt and scheduled a retry, so a row
|
||||||
|
* read "Searched 3 times, no download clients are enabled · next check
|
||||||
|
* in 6 hours" about a check that could not happen.
|
||||||
|
*
|
||||||
|
* The backend half is `TestNoProvidersMeansNoAttempt`. This is the row.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
|
import '@components/downloads-view/downloads-view';
|
||||||
|
import { stub, emit, flush, resetHarness } from '@test/support/harness';
|
||||||
|
import { Events } from '../../src/events';
|
||||||
|
import { fixture, shadowAll } from '@test/support/render';
|
||||||
|
|
||||||
|
/** A request that has been tried and is waiting on a retry — the shape
|
||||||
|
* a list with a client in it produces. */
|
||||||
|
const WAITING = {
|
||||||
|
id: 1,
|
||||||
|
mbid: 'rg-1',
|
||||||
|
entity: 'release-group',
|
||||||
|
libraryId: 1,
|
||||||
|
artist: 'Aurora Fields',
|
||||||
|
title: 'Glass Harbour',
|
||||||
|
state: 'wanted',
|
||||||
|
attempts: 3,
|
||||||
|
lastError: 'no source has it yet',
|
||||||
|
nextTryAt: new Date(Date.now() + 6 * 3600_000).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER = {
|
||||||
|
id: 1,
|
||||||
|
kind: 'slskd',
|
||||||
|
name: 'Sound',
|
||||||
|
enabled: true,
|
||||||
|
priority: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The download store is a singleton whose `init()` runs once per
|
||||||
|
* session, so a second mount does not re-read the provider list. The
|
||||||
|
* event is how the app itself learns a client was added, and is what
|
||||||
|
* makes this test independent of which case ran first.
|
||||||
|
*/
|
||||||
|
async function view(providers: unknown[]): Promise<LitElement> {
|
||||||
|
stub('download.Service.ListProviders', providers);
|
||||||
|
|
||||||
|
const el = await fixture<LitElement>('downloads-view');
|
||||||
|
|
||||||
|
emit(Events.DownloadProvidersChanged);
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = (el: LitElement) =>
|
||||||
|
shadowAll(el, '.detail').map((d) => d.textContent!.trim());
|
||||||
|
|
||||||
|
describe('a request row with no download client', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
stub('download.Service.ProviderKinds', []);
|
||||||
|
stub('download.Service.ListProviders', []);
|
||||||
|
stub('download.Service.ListDownloads', []);
|
||||||
|
stub('download.Service.ListRequests', [WAITING]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not promise a check that cannot happen', async () => {
|
||||||
|
const el = await view([]);
|
||||||
|
|
||||||
|
expect(details(el)).toHaveLength(1);
|
||||||
|
expect(details(el)[0]).toBe(
|
||||||
|
'On your list — no download client to search with',
|
||||||
|
);
|
||||||
|
expect(details(el)[0]).not.toMatch(/next check/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the retry schedule again once a client exists', async () => {
|
||||||
|
const el = await view([PROVIDER]);
|
||||||
|
|
||||||
|
expect(details(el)[0]).toMatch(/next check/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user