Skip to content

Commit 3e934e2

Browse files
authored
Merge pull request #3452 from shirsakm/scrobbler-log-importer
LB-1852: Add file importer for Audioscrobbler spec
2 parents c4897a0 + dc0c578 commit 3e934e2

File tree

12 files changed

+394
-46
lines changed

12 files changed

+394
-46
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,6 @@ listenbrainz_spark/recommendations/html_files*
148148

149149
# IDE configs
150150
.idea
151+
152+
# Test data
153+
!**/*.scrobbler.log

admin/sql/create_types.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@ CREATE TYPE user_data_export_status_type AS ENUM ('in_progress', 'waiting', 'com
2525

2626
CREATE TYPE user_data_export_type_type AS ENUM ('export_all_user_data');
2727

28-
CREATE TYPE user_data_import_service_type AS ENUM ('spotify', 'applemusic', 'listenbrainz', 'librefm', 'maloja', 'panoscrobbler');
28+
CREATE TYPE user_data_import_service_type AS ENUM ('spotify', 'applemusic', 'listenbrainz', 'librefm', 'maloja', 'panoscrobbler', 'audioscrobbler');
2929

3030
CREATE TYPE data_dump_type_type AS ENUM ('incremental', 'full');
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
BEGIN;
2+
ALTER TYPE user_data_import_service_type ADD VALUE 'audioscrobbler';
3+
COMMIT;

frontend/js/src/settings/import/ImportListens.tsx

Lines changed: 83 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import Loader from "../../components/Loader";
2020

2121
type ImportListensLoaderData = {
2222
user_has_email: boolean;
23+
pg_timezones: Array<[string, string]>;
24+
user_timezone: string;
2325
};
2426

2527
enum ImportStatus {
@@ -36,6 +38,7 @@ enum Services {
3638
librefm = "Libre.fm",
3739
panoscrobbler = "PanoScrobbler",
3840
maloja = "Maloja",
41+
audioscrobbler = "Audioscrobbler/Rockbox",
3942
}
4043
const acceptedFileTypes = {
4144
[Services.spotify]: ".zip",
@@ -44,6 +47,7 @@ const acceptedFileTypes = {
4447
[Services.librefm]: ".csv",
4548
[Services.panoscrobbler]: ".jsonl",
4649
[Services.maloja]: ".json",
50+
[Services.audioscrobbler]: ".log",
4751
};
4852
type ImportMetadata = {
4953
filename: string;
@@ -252,7 +256,7 @@ function renderImport(
252256

253257
export default function ImportListens() {
254258
const data = useLoaderData() as ImportListensLoaderData;
255-
const { user_has_email: userHasEmail } = data;
259+
const { user_has_email: userHasEmail, pg_timezones, user_timezone } = data;
256260

257261
const { currentUser, APIService } = React.useContext(GlobalAppContext);
258262

@@ -511,9 +515,9 @@ export default function ImportListens() {
511515
<br />
512516
For <b>{nonZipServices.join(", ")}</b>: please upload single files
513517
directly (
514-
{nonZipServices.map((s) => (
515-
<mark>{acceptedFileTypes[s]}, </mark>
516-
))}{" "}
518+
<mark>
519+
{nonZipServices.map((s) => acceptedFileTypes[s]).join(", ")}
520+
</mark>{" "}
517521
respectively).
518522
</p>
519523
</div>
@@ -560,44 +564,84 @@ export default function ImportListens() {
560564
onChange={(e) => setFileSelected(!!e.target.files?.length)}
561565
/>
562566
</div>
567+
</div>
563568

564-
<div style={{ minWidth: "15em" }}>
565-
<label className="form-label" htmlFor="start-datetime">
566-
Start import from (optional):
567-
</label>
568-
<input
569-
type="date"
570-
id="start-datetime"
571-
className="form-control"
572-
max={new Date().toISOString()}
573-
name="from_date"
574-
title="Date and time to start import at"
575-
/>
576-
</div>
577-
578-
<div style={{ minWidth: "15em" }}>
579-
<label className="form-label" htmlFor="end-datetime">
580-
End date for import (optional):
581-
</label>
582-
<input
583-
type="date"
584-
id="end-datetime"
585-
className="form-control"
586-
max={new Date().toISOString()}
587-
name="to_date"
588-
title="Date and time to end import at"
569+
<details className="mt-3">
570+
<summary>
571+
<FontAwesomeIcon
572+
icon={faChevronCircleRight}
573+
size="sm"
574+
className="summary-indicator"
589575
/>
576+
Additional options
577+
</summary>
578+
<div className="flex flex-wrap mt-3" style={{ gap: "1em" }}>
579+
<div style={{ minWidth: "15em" }}>
580+
<label className="form-label" htmlFor="start-datetime">
581+
Start import from (optional):
582+
</label>
583+
<input
584+
type="date"
585+
id="start-datetime"
586+
className="form-control"
587+
max={new Date().toISOString()}
588+
name="from_date"
589+
title="Date and time to start import at"
590+
/>
591+
</div>
592+
593+
<div style={{ minWidth: "15em" }}>
594+
<label className="form-label" htmlFor="end-datetime">
595+
End date for import (optional):
596+
</label>
597+
<input
598+
type="date"
599+
id="end-datetime"
600+
className="form-control"
601+
max={new Date().toISOString()}
602+
name="to_date"
603+
title="Date and time to end import at"
604+
/>
605+
</div>
606+
607+
<div style={{ minWidth: "15em" }}>
608+
<label className="form-label" htmlFor="timezone">
609+
Timezone (optional):
610+
</label>
611+
<select
612+
className="form-select"
613+
id="timezone"
614+
name="timezone"
615+
defaultValue={user_timezone}
616+
title="Timezone fallback for ambiguous timestamps"
617+
disabled={selectedService !== "audioscrobbler"}
618+
>
619+
<option value="">
620+
Use profile timezone ({user_timezone})
621+
</option>
622+
{pg_timezones.map((zone: string[]) => {
623+
return (
624+
<option key={zone[0]} value={zone[0]}>
625+
{zone[0]} ({zone[1]})
626+
</option>
627+
);
628+
})}
629+
</select>
630+
</div>
590631
</div>
591-
592-
<div style={{ flex: 0, alignSelf: "end", minWidth: "15em" }}>
593-
<button
594-
type="submit"
595-
className="btn btn-success"
596-
disabled={hasAnImportInProgress || !fileSelected}
597-
>
598-
Import Listens
599-
</button>
600-
</div>
632+
</details>
633+
634+
<div className="mt-4" style={{ minWidth: "15em" }}>
635+
<button
636+
type="submit"
637+
className="btn btn-success"
638+
style={{
639+
padding: "1rem 2.5rem",
640+
}}
641+
disabled={hasAnImportInProgress || !fileSelected}
642+
>
643+
Import Listens
644+
</button>
601645
</div>
602646
</form>
603647
</div>

frontend/js/tests/user/import-listens/ImportListens.test.tsx

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,15 @@ describe("ImportListensPage", () => {
7979

8080
server = setupServer(
8181
http.post("/settings/import/", (req) => {
82-
return HttpResponse.json({ user_has_email: true });
82+
return HttpResponse.json({
83+
user_has_email: true,
84+
pg_timezones: [
85+
["America/New_York", "UTC-05:00"],
86+
["Europe/London", "UTC+00:00"],
87+
["Asia/Tokyo", "UTC+09:00"],
88+
],
89+
user_timezone: "America/New_York",
90+
});
8391
}),
8492
http.get("/1/import-listens/list/", (req) => {
8593
return HttpResponse.json(mockImports);
@@ -112,12 +120,39 @@ describe("ImportListensPage", () => {
112120
it("renders submission form correctly", async () => {
113121
renderWithProviders(<RouterProvider router={router} />, {}, { wrapper: ReactQueryWrapper }, false);
114122
await waitFor(() => {
115-
expect(screen.getByText(/start import from/i)).toBeInTheDocument();
116-
expect(screen.getByText(/end date for import/i)).toBeInTheDocument();
117123
expect(screen.getByText(/select Service/i)).toBeInTheDocument();
118124
expect(screen.getByText(/Select your .zip file/i)).toBeInTheDocument();
119125
expect(screen.getByRole("button", { name: /import listens/i })).toBeInTheDocument();
120126
});
127+
128+
const accordionSummary = screen.getByText(/additional options/i);
129+
expect(accordionSummary).toBeInTheDocument();
130+
await user.click(accordionSummary);
131+
132+
await waitFor(() => {
133+
expect(screen.getByLabelText(/timezone/i)).toBeInTheDocument();
134+
expect(screen.getByText(/start import from/i)).toBeInTheDocument();
135+
expect(screen.getByText(/end date for import/i)).toBeInTheDocument();
136+
});
137+
});
138+
139+
it("disables timezone selection for non-audioscrobbler services", async () => {
140+
renderWithProviders(<RouterProvider router={router} />, {}, { wrapper: ReactQueryWrapper }, false);
141+
const accordionSummary = screen.getByText(/additional options/i);
142+
await user.click(accordionSummary);
143+
144+
await waitFor(() => {
145+
const timezoneSelect = screen.getByLabelText(/timezone/i);
146+
expect(timezoneSelect).toBeDisabled();
147+
});
148+
149+
const serviceSelect = screen.getByLabelText(/select service/i);
150+
await user.selectOptions(serviceSelect, "audioscrobbler");
151+
152+
await waitFor(() => {
153+
const timezoneSelect = screen.getByLabelText(/timezone/i);
154+
expect(timezoneSelect).not.toBeDisabled();
155+
});
121156
});
122157

123158
it("enables the import button after a file is uploaded", async () => {

listenbrainz/background/listens_importer/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from listenbrainz.background.listens_importer.maloja import MalojaListensImporter
88
from listenbrainz.background.listens_importer.spotify import SpotifyListensImporter
99
from listenbrainz.background.listens_importer.panoscrobbler import PanoScrobblerListensImporter
10+
from listenbrainz.background.listens_importer.audioscrobbler import AudioscrobblerListensImporter
1011

1112

1213
def import_listens(db_conn, ts_conn, user_id, bg_task_metadata):
@@ -36,6 +37,8 @@ def import_listens(db_conn, ts_conn, user_id, bg_task_metadata):
3637
importer = PanoScrobblerListensImporter(db_conn, ts_conn)
3738
elif service == "maloja":
3839
importer = MalojaListensImporter(db_conn, ts_conn)
40+
elif service == "audioscrobbler":
41+
importer = AudioscrobblerListensImporter(db_conn, ts_conn)
3942
else:
4043
msg = f"Unsupported service: {service}"
4144
update_import_task(db_conn, import_id, status="failed", progress=msg)

0 commit comments

Comments
 (0)