feat: import listens from spotify extended streaming history (#305)

This commit is contained in:
Julian Tölle 2023-10-01 03:35:02 +02:00 committed by GitHub
parent 23d7ea0995
commit 7140cb0679
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 1051 additions and 215 deletions

View file

@ -2,6 +2,7 @@ import React from "react";
import { Route, Routes } from "react-router-dom";
import { AuthApiTokens } from "./components/AuthApiTokens";
import { Footer } from "./components/Footer";
import { ImportListens } from "./components/ImportListens";
import { LoginFailure } from "./components/LoginFailure";
import { LoginLoading } from "./components/LoginLoading";
import { NavBar } from "./components/NavBar";
@ -53,6 +54,7 @@ export function App() {
element={<ReportTopGenres />}
/>
<Route path="/auth/api-tokens" element={<AuthApiTokens />} />
<Route path="/import" element={<ImportListens />} />
</Routes>
)}
{!user && (

View file

@ -14,6 +14,8 @@ import { TopGenresItem } from "./entities/top-genres-item";
import { TopGenresOptions } from "./entities/top-genres-options";
import { TopTracksItem } from "./entities/top-tracks-item";
import { TopTracksOptions } from "./entities/top-tracks-options";
import { SpotifyExtendedStreamingHistoryItem } from "./entities/spotify-extended-streaming-history-item";
import { ExtendedStreamingHistoryStatus } from "./entities/extended-streaming-history-status";
export class UnauthenticatedError extends Error {}
@ -276,7 +278,7 @@ export const revokeApiToken = async (
id: string,
client: AxiosInstance,
): Promise<void> => {
const res = await client.delete<NewApiToken>(`/api/v1/auth/api-tokens/${id}`);
const res = await client.delete(`/api/v1/auth/api-tokens/${id}`);
switch (res.status) {
case 200: {
@ -290,3 +292,50 @@ export const revokeApiToken = async (
}
}
};
export const importExtendedStreamingHistory = async (
listens: SpotifyExtendedStreamingHistoryItem[],
client: AxiosInstance
): Promise<void> => {
const res = await client.post(`/api/v1/import/extended-streaming-history`, {
listens,
});
switch (res.status) {
case 201: {
break;
}
case 401: {
throw new UnauthenticatedError(`No token or token expired`);
}
default: {
throw new Error(
`Unable to importExtendedStreamingHistory: ${res.status}`
);
}
}
};
export const getExtendedStreamingHistoryStatus = async (
client: AxiosInstance
): Promise<ExtendedStreamingHistoryStatus> => {
const res = await client.get<ExtendedStreamingHistoryStatus>(
`/api/v1/import/extended-streaming-history/status`
);
switch (res.status) {
case 200: {
break;
}
case 401: {
throw new UnauthenticatedError(`No token or token expired`);
}
default: {
throw new Error(
`Unable to getExtendedStreamingHistoryStatus: ${res.status}`
);
}
}
return res.data;
};

View file

@ -0,0 +1,4 @@
export interface ExtendedStreamingHistoryStatus {
total: number;
imported: number;
}

View file

@ -0,0 +1,4 @@
export interface SpotifyExtendedStreamingHistoryItem {
ts: string;
spotify_track_uri: string;
}

View file

@ -2,7 +2,6 @@ import { format, formatDistanceToNow } from "date-fns";
import React, { FormEvent, useCallback, useMemo, useState } from "react";
import { ApiToken, NewApiToken } from "../api/entities/api-token";
import { useApiTokens } from "../hooks/use-api";
import { useAuthProtection } from "../hooks/use-auth-protection";
import { SpinnerIcon } from "../icons/Spinner";
import TrashcanIcon from "../icons/Trashcan";
import { Spinner } from "./ui/Spinner";

View file

@ -0,0 +1,280 @@
import React, { useCallback, useEffect, useState } from "react";
import Files from "react-files";
import type { ReactFile } from "react-files";
import {
useSpotifyImportExtendedStreamingHistory,
useSpotifyImportExtendedStreamingHistoryStatus,
} from "../hooks/use-api";
import { SpotifyExtendedStreamingHistoryItem } from "../api/entities/spotify-extended-streaming-history-item";
import { ErrorIcon } from "../icons/Error";
import { numberToPercent } from "../util/numberToPercent";
import { Button } from "./ui/button";
import { Table, TableBody, TableCell, TableRow } from "./ui/table";
import { Badge } from "./ui/badge";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./ui/card";
import { Code } from "./ui/code";
export const ImportListens: React.FC = () => {
return (
<>
<div className="flex justify-between">
<p className="text-2xl font-normal">
Import Listens from Spotify Extended Streaming History
</p>
</div>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-4 m-2">
<p className="my-4">
Here you can import your full Spotify Listen history that was exported
from the{" "}
<a
target="blank"
href="https://www.spotify.com/us/account/privacy/"
className="underline"
>
Extended streaming history
</a>
.
</p>
<p className="my-4">
The extended streaming history contains additional personally
identifiable data such as the IP address of the listen (which can be
linked to locations). To avoid saving this on the server, the data is
preprocessed in your web browser and only the necessary data
(timestamp & track ID) are sent to the server.
</p>
<p className="my-4">
If an error occurs, you can always retry uploading the file, Listory
deduplicates any listens to make sure that everything is saved only
once.
</p>
<FileUpload />
<ImportProgress />
</div>
</>
);
};
interface FileData {
file: ReactFile;
status: Status;
error?: Error;
}
enum Status {
Select,
Import,
Finished,
Error,
}
const FileUpload: React.FC = () => {
// Using a map is ... meh, need to wrap all state updates in `new Map()` so react re-renders
const [fileMap, setFileMap] = useState<Map<ReactFile["id"], FileData>>(
new Map(),
);
const [status, setStatus] = useState<Status>(Status.Select);
const addFiles = useCallback(
(files: ReactFile[]) => {
setFileMap((_fileMap) => {
files.forEach((file) =>
_fileMap.set(file.id, { file, status: Status.Select }),
);
return new Map(_fileMap);
});
},
[setFileMap],
);
const updateFile = useCallback((data: FileData) => {
setFileMap((_fileMap) => new Map(_fileMap.set(data.file.id, data)));
}, []);
const clearFiles = useCallback(() => {
setFileMap(new Map());
}, [setFileMap]);
const { importHistory } = useSpotifyImportExtendedStreamingHistory();
const handleImport = useCallback(async () => {
setStatus(Status.Import);
let errorOccurred = false;
for (const data of fileMap.values()) {
data.status = Status.Import;
updateFile(data);
let items: SpotifyExtendedStreamingHistoryItem[];
// Scope so these tmp variables can be GC-ed ASAP
{
const fileContent = await data.file.text();
const rawItems = JSON.parse(
fileContent,
) as SpotifyExtendedStreamingHistoryItem[];
items = rawItems
.filter(({ spotify_track_uri }) => spotify_track_uri !== null)
.map(({ ts, spotify_track_uri }) => ({
ts,
spotify_track_uri,
}));
}
try {
await importHistory(items);
data.status = Status.Finished;
} catch (err) {
data.error = err as Error;
data.status = Status.Error;
errorOccurred = true;
}
updateFile(data);
}
if (!errorOccurred) {
setStatus(Status.Finished);
}
}, [fileMap, importHistory, updateFile]);
return (
<Card className="mb-5">
<CardHeader>
<CardTitle>File Upload</CardTitle>
<CardDescription>
Select <Code>endsong_XY.json</Code> files here and start the import.
</CardDescription>
</CardHeader>
<CardContent>
<Files
className="shadow-inner bg-gray-200 dark:bg-gray-700 rounded p-4 text-center cursor-pointer"
dragActiveClassName=""
onChange={addFiles}
accepts={["application/json"]}
multiple
clickable
>
Drop files here or click to upload
</Files>
<Table>
<TableBody>
{Array.from(fileMap.values()).map((data) => (
<File key={data.file.id} data={data} />
))}
</TableBody>
</Table>
</CardContent>
<CardFooter className="flex gap-x-2">
<Button
onClick={() =>
handleImport().catch((e) => console.error("Import Failed:", e))
}
variant="secondary"
disabled={status !== Status.Select}
>
Start Import
</Button>
<Button
onClick={clearFiles}
variant="secondary"
disabled={status !== Status.Select}
>
Remove All Files
</Button>
</CardFooter>
</Card>
);
};
const File: React.FC<{ data: FileData }> = ({ data }) => {
const hasErrors = data.status === Status.Error && data.error;
return (
<TableRow>
<TableCell>{data.file.name}</TableCell>
<TableCell className="text-sm font-thin">
{data.file.sizeReadable}
</TableCell>
<TableCell className="text-right">
{data.status === Status.Select && <Badge>Prepared for import!</Badge>}
{data.status === Status.Import && <Badge>Loading!</Badge>}
{data.status === Status.Finished && <Badge>Check!</Badge>}
{hasErrors && (
<Badge variant="destructive">
<ErrorIcon />
{data.error?.message}
</Badge>
)}
</TableCell>
</TableRow>
);
};
const ImportProgress: React.FC = () => {
const {
importStatus: { total, imported },
isLoading,
reload,
} = useSpotifyImportExtendedStreamingHistoryStatus();
useEffect(() => {
const interval = setInterval(() => {
if (!isLoading) {
reload();
}
}, 1000);
return () => clearInterval(interval);
}, [isLoading, reload]);
return (
<Card>
<CardHeader>
<CardTitle>Import Progress</CardTitle>
<CardDescription>
Shows how many of the submitted listens are already imported and
visible to you. This will take a while, and the process might halt for
a few minutes if we hit the Spotify API rate limit. If this is not
finished after a few hours, please contact your Listory administrator.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex pb-2">
<div className="md:flex w-10/12">
<div className={`md:w-full font-bold`}>
Imported
<br />
{imported}
</div>
</div>
<div className="w-2/12 text-right">
Total
<br />
{total}
</div>
</div>
{total > 0 && (
<div className="h-2 w-full bg-gradient-to-r from-teal-200/25 via-green-400 to-violet-400 dark:from-teal-700/25 dark:via-green-600/85 dark:to-amber-500 flex flex-row-reverse">
<div
style={{ width: numberToPercent(1 - imported / total) }}
className="h-full bg-gray-100 dark:bg-gray-900"
></div>
</div>
)}
</CardContent>
</Card>
);
};

View file

@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { User } from "../api/entities/user";
import { useAuth } from "../hooks/use-auth";
import { CogwheelIcon } from "../icons/Cogwheel";
import { ImportIcon } from "../icons/Import";
import { SpotifyLogo } from "../icons/Spotify";
import { Avatar, AvatarImage, AvatarFallback } from "./ui/avatar";
import {
@ -188,6 +189,12 @@ const NavUserInfo: React.FC<{ user: User }> = ({ user }) => {
API Tokens
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/import">
<ImportIcon className="w-5 h-5 fill-current pr-2" />
Import Listens
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -50,7 +50,7 @@ export const RecentListens: React.FC = () => {
)}
<div>
{recentListens.length > 0 && (
<Table className="table-auto w-full text-base">
<Table className="table-auto w-full">
<TableBody>
{recentListens.map((listen) => (
<ListenItem listen={listen} key={listen.id} />

View file

@ -1,4 +1,5 @@
import React from "react";
import { numberToPercent } from "../../util/numberToPercent";
export interface TopListItemProps {
title: string;
@ -42,9 +43,3 @@ export const TopListItem: React.FC<TopListItemProps> = ({
const isMaxCountValid = (maxCount: number) =>
!(Number.isNaN(maxCount) || maxCount === 0);
const numberToPercent = (ratio: number) =>
ratio.toLocaleString(undefined, {
style: "percent",
minimumFractionDigits: 2,
});

View file

@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "src/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border border-gray-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 dark:border-gray-800 dark:focus:ring-gray-300",
{
variants: {
variant: {
default:
"border-transparent bg-gray-900 text-gray-50 hover:bg-gray-900/80 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-50/80",
secondary:
"border-transparent bg-gray-100 text-gray-900 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80",
destructive:
"border-transparent bg-red-500 text-gray-50 hover:bg-red-500/80 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/80",
outline: "text-gray-900 dark:text-gray-50",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View file

@ -0,0 +1,86 @@
import * as React from "react";
import { cn } from "src/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-gray-200 bg-white text-gray-900 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-gray-500 dark:text-gray-400", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View file

@ -0,0 +1,9 @@
import React, { PropsWithChildren } from "react";
export const Code: React.FC<PropsWithChildren> = ({ children }) => {
return (
<code className="tracking-wide font-mono bg-gray-200 dark:bg-gray-600 rounded-md px-1">
{children}
</code>
);
};

View file

@ -1,6 +1,6 @@
import * as React from "react"
import * as React from "react";
import { cn } from "src/lib/utils"
import { cn } from "src/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
@ -9,20 +9,20 @@ const Table = React.forwardRef<
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
className={cn("w-full caption-bottom", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
@ -33,8 +33,8 @@ const TableBody = React.forwardRef<
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
@ -42,11 +42,14 @@ const TableFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-gray-900 font-medium text-gray-50 dark:bg-gray-50 dark:text-gray-900", className)}
className={cn(
"bg-gray-900 font-medium text-gray-50 dark:bg-gray-50 dark:text-gray-900",
className,
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
@ -56,12 +59,12 @@ const TableRow = React.forwardRef<
ref={ref}
className={cn(
"border-b transition-colors hover:bg-gray-100/50 data-[state=selected]:bg-gray-100 dark:hover:bg-gray-800/50 dark:data-[state=selected]:bg-gray-800",
className
className,
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
@ -71,12 +74,12 @@ const TableHead = React.forwardRef<
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-gray-500 [&:has([role=checkbox])]:pr-0 dark:text-gray-400",
className
className,
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
@ -87,8 +90,8 @@ const TableCell = React.forwardRef<
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
@ -99,8 +102,8 @@ const TableCaption = React.forwardRef<
className={cn("mt-4 text-sm text-gray-500 dark:text-gray-400", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
));
TableCaption.displayName = "TableCaption";
export {
Table,
@ -111,4 +114,4 @@ export {
TableRow,
TableCell,
TableCaption,
}
};

View file

@ -2,12 +2,14 @@ import { useCallback, useMemo } from "react";
import {
createApiToken,
getApiTokens,
getExtendedStreamingHistoryStatus,
getListensReport,
getRecentListens,
getTopAlbums,
getTopArtists,
getTopGenres,
getTopTracks,
importExtendedStreamingHistory,
revokeApiToken,
} from "../api/api";
import { ListenReportOptions } from "../api/entities/listen-report-options";
@ -18,6 +20,7 @@ import { TopGenresOptions } from "../api/entities/top-genres-options";
import { TopTracksOptions } from "../api/entities/top-tracks-options";
import { useApiClient } from "./use-api-client";
import { useAsync } from "./use-async";
import { SpotifyExtendedStreamingHistoryItem } from "../api/entities/spotify-extended-streaming-history-item";
const INITIAL_EMPTY_ARRAY: [] = [];
Object.freeze(INITIAL_EMPTY_ARRAY);
@ -143,9 +146,7 @@ export const useApiTokens = () => {
const createToken = useCallback(
async (description: string) => {
const apiToken = await createApiToken(description, client);
console.log("apiToken created", apiToken);
await reload();
console.log("reloaded data");
return apiToken;
},
@ -162,3 +163,38 @@ export const useApiTokens = () => {
return { apiTokens, isLoading, error, createToken, revokeToken };
};
export const useSpotifyImportExtendedStreamingHistory = () => {
const { client } = useApiClient();
const importHistory = useCallback(
async (listens: SpotifyExtendedStreamingHistoryItem[]) => {
return importExtendedStreamingHistory(listens, client);
},
[client]
);
const getStatus = useCallback(async () => {
return getExtendedStreamingHistoryStatus(client);
}, [client]);
return { importHistory, getStatus };
};
export const useSpotifyImportExtendedStreamingHistoryStatus = () => {
const { client } = useApiClient();
const fetchData = useMemo(
() => () => getExtendedStreamingHistoryStatus(client),
[client]
);
const {
value: importStatus,
pending: isLoading,
error,
reload,
} = useAsync(fetchData, { total: 0, imported: 0 });
return { importStatus, isLoading, error, reload };
};

View file

@ -1,16 +0,0 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "./use-auth";
export function useAuthProtection() {
const { user } = useAuth();
const navigate = useNavigate();
const requireUser = useCallback(async () => {
if (!user) {
navigate("/");
}
}, [user, navigate]);
return { requireUser };
}

View file

@ -0,0 +1,14 @@
import React from "react";
export const ErrorIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
return (
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 50 50"
fill="#D75A4A"
>
<circle fill="fill" cx="25" cy="25" r="25" />
</svg>
);
};

View file

@ -0,0 +1,12 @@
import * as React from "react";
export const ImportIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
viewBox="0 0 60.903 60.903"
{...props}
>
<path d="M49.561 16.464H39.45v6h10.111c3.008 0 5.341 1.535 5.341 2.857v26.607c0 1.321-2.333 2.858-5.341 2.858H11.34c-3.007 0-5.34-1.537-5.34-2.858V25.324c0-1.322 2.333-2.858 5.34-2.858h10.11v-6H11.34C4.981 16.466 0 20.357 0 25.324v26.605c0 4.968 4.981 8.857 11.34 8.857h38.223c6.357 0 11.34-3.891 11.34-8.857V25.324c-.001-4.969-4.982-8.86-11.342-8.86z" />
<path d="M39.529 29.004a2.99 2.99 0 0 0-2.121.88l-3.756 3.755V3.117a3 3 0 0 0-6 0v30.724l-3.959-3.958a2.992 2.992 0 0 0-4.242 0 2.997 2.997 0 0 0 0 4.241l8.957 8.957a2.988 2.988 0 0 0 2.12.877h.045c.768 0 1.534-.291 2.12-.877l8.957-8.957a2.997 2.997 0 0 0-2.121-5.12z" />
</svg>
);

36
frontend/src/react-files.d.ts vendored Normal file
View file

@ -0,0 +1,36 @@
//// <reference types="react" />
declare module "react-files" {
declare const Files: React.FC<
Partial<{
accepts: string[];
children: React.ReactNode;
className: string;
clickable: boolean;
dragActiveClassName: string;
inputProps: unknown;
multiple: boolean;
maxFiles: number;
maxFileSize: number;
minFileSize: number;
name: string;
onChange: (files: ReactFile[]) => void;
onDragEnter: () => void;
onDragLeave: () => void;
onError: (
error: { code: number; message: string },
file: ReactFile
) => void;
style: object;
}>
>;
export type ReactFile = File & {
id: string;
extension: string;
sizeReadable: string;
preview: { type: "image"; url: string } | { type: "file" };
};
export default Files;
}

View file

@ -0,0 +1,5 @@
export const numberToPercent = (ratio: number) =>
ratio.toLocaleString(undefined, {
style: "percent",
minimumFractionDigits: 2,
});