mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 13:38:15 -08:00
fix: respect client timezone for requests (#119)
* maybe fixed for total listen activity * maybe actually fixed now * fix unset location panics
This commit is contained in:
parent
2925425750
commit
f48dd6c039
13 changed files with 368 additions and 343 deletions
|
|
@ -63,7 +63,7 @@ export default function ActivityGrid({
|
||||||
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { theme, themeName } = useTheme();
|
const { theme } = useTheme();
|
||||||
const color = getPrimaryColor(theme);
|
const color = getPrimaryColor(theme);
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
|
|
@ -129,14 +129,7 @@ export default function ActivityGrid({
|
||||||
}
|
}
|
||||||
|
|
||||||
v = Math.min(v, t);
|
v = Math.min(v, t);
|
||||||
if (themeName === "pearl") {
|
return ((v - t) / t) * 0.8;
|
||||||
// special case for the only light theme lol
|
|
||||||
// could be generalized by pragmatically comparing the
|
|
||||||
// lightness of the bg vs the primary but eh
|
|
||||||
return (t - v) / t;
|
|
||||||
} else {
|
|
||||||
return ((v - t) / t) * 0.8;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHUNK_SIZE = 26 * 7;
|
const CHUNK_SIZE = 26 * 7;
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,19 @@ import {
|
||||||
} from "react-router";
|
} from "react-router";
|
||||||
|
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
import './themes.css'
|
import "./themes.css";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ThemeProvider } from './providers/ThemeProvider';
|
import { ThemeProvider } from "./providers/ThemeProvider";
|
||||||
import Sidebar from "./components/sidebar/Sidebar";
|
import Sidebar from "./components/sidebar/Sidebar";
|
||||||
import Footer from "./components/Footer";
|
import Footer from "./components/Footer";
|
||||||
import { AppProvider } from "./providers/AppProvider";
|
import { AppProvider } from "./providers/AppProvider";
|
||||||
|
import { initTimezoneCookie } from "./tz";
|
||||||
|
|
||||||
|
initTimezoneCookie();
|
||||||
|
|
||||||
// Create a client
|
// Create a client
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||||
|
|
@ -35,14 +38,23 @@ export const links: Route.LinksFunction = () => [
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" style={{backgroundColor: 'black'}}>
|
<html lang="en" style={{ backgroundColor: "black" }}>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
href="/favicon-96x96.png"
|
||||||
|
sizes="96x96"
|
||||||
|
/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
<meta name="apple-mobile-web-app-title" content="Koito" />
|
<meta name="apple-mobile-web-app-title" content="Koito" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<Meta />
|
<Meta />
|
||||||
|
|
@ -60,71 +72,71 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppProvider>
|
<AppProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<div className="flex-col flex sm:flex-row">
|
<div className="flex-col flex sm:flex-row">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex flex-col items-center mx-auto w-full ml-0 sm:ml-[40px]">
|
<div className="flex flex-col items-center mx-auto w-full ml-0 sm:ml-[40px]">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HydrateFallback() {
|
export function HydrateFallback() {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
export function ErrorBoundary() {
|
||||||
const error = useRouteError();
|
const error = useRouteError();
|
||||||
let message = "Oops!";
|
let message = "Oops!";
|
||||||
let details = "An unexpected error occurred.";
|
let details = "An unexpected error occurred.";
|
||||||
let stack: string | undefined;
|
let stack: string | undefined;
|
||||||
|
|
||||||
if (isRouteErrorResponse(error)) {
|
if (isRouteErrorResponse(error)) {
|
||||||
message = error.status === 404 ? "404" : "Error";
|
message = error.status === 404 ? "404" : "Error";
|
||||||
details = error.status === 404
|
details =
|
||||||
|
error.status === 404
|
||||||
? "The requested page could not be found."
|
? "The requested page could not be found."
|
||||||
: error.statusText || details;
|
: error.statusText || details;
|
||||||
} else if (import.meta.env.DEV && error instanceof Error) {
|
} else if (import.meta.env.DEV && error instanceof Error) {
|
||||||
details = error.message;
|
details = error.message;
|
||||||
stack = error.stack;
|
stack = error.stack;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const title = `${message} - Koito`;
|
||||||
|
|
||||||
const title = `${message} - Koito`
|
return (
|
||||||
|
<AppProvider>
|
||||||
return (
|
<ThemeProvider>
|
||||||
<AppProvider>
|
<title>{title}</title>
|
||||||
<ThemeProvider>
|
<div className="flex">
|
||||||
<title>{title}</title>
|
<Sidebar />
|
||||||
<div className="flex">
|
<div className="w-full flex flex-col">
|
||||||
<Sidebar />
|
<main className="pt-16 p-4 container mx-auto flex-grow">
|
||||||
<div className="w-full flex flex-col">
|
<div className="flex gap-4 items-end">
|
||||||
<main className="pt-16 p-4 container mx-auto flex-grow">
|
<img className="w-[200px] rounded" src="../yuu.jpg" />
|
||||||
<div className="flex gap-4 items-end">
|
<div>
|
||||||
<img className="w-[200px] rounded" src="../yuu.jpg" />
|
<h1>{message}</h1>
|
||||||
<div>
|
<p>{details}</p>
|
||||||
<h1>{message}</h1>
|
|
||||||
<p>{details}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{stack && (
|
|
||||||
<pre className="w-full p-4 overflow-x-auto">
|
|
||||||
<code>{stack}</code>
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ThemeProvider>
|
</div>
|
||||||
</AppProvider>
|
{stack && (
|
||||||
);
|
<pre className="w-full p-4 overflow-x-auto">
|
||||||
|
<code>{stack}</code>
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
client/app/tz.ts
Normal file
10
client/app/tz.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
export function initTimezoneCookie() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
if (document.cookie.includes("tz=")) return;
|
||||||
|
|
||||||
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
if (!tz) return;
|
||||||
|
|
||||||
|
document.cookie = `tz=${tz}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ VALUES ($1, $2, $3, $4)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- name: GetLastListensPaginated :many
|
-- name: GetLastListensPaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
l.*,
|
l.*,
|
||||||
t.title AS track_title,
|
t.title AS track_title,
|
||||||
t.release_id AS release_id,
|
t.release_id AS release_id,
|
||||||
|
|
@ -16,31 +16,31 @@ ORDER BY l.listened_at DESC
|
||||||
LIMIT $3 OFFSET $4;
|
LIMIT $3 OFFSET $4;
|
||||||
|
|
||||||
-- name: GetLastListensFromArtistPaginated :many
|
-- name: GetLastListensFromArtistPaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
l.*,
|
l.*,
|
||||||
t.title AS track_title,
|
t.title AS track_title,
|
||||||
t.release_id AS release_id,
|
t.release_id AS release_id,
|
||||||
get_artists_for_track(t.id) AS artists
|
get_artists_for_track(t.id) AS artists
|
||||||
FROM listens l
|
FROM listens l
|
||||||
JOIN tracks_with_title t ON l.track_id = t.id
|
JOIN tracks_with_title t ON l.track_id = t.id
|
||||||
JOIN artist_tracks at ON t.id = at.track_id
|
JOIN artist_tracks at ON t.id = at.track_id
|
||||||
WHERE at.artist_id = $5
|
WHERE at.artist_id = $5
|
||||||
AND l.listened_at BETWEEN $1 AND $2
|
AND l.listened_at BETWEEN $1 AND $2
|
||||||
ORDER BY l.listened_at DESC
|
ORDER BY l.listened_at DESC
|
||||||
LIMIT $3 OFFSET $4;
|
LIMIT $3 OFFSET $4;
|
||||||
|
|
||||||
-- name: GetFirstListenFromArtist :one
|
-- name: GetFirstListenFromArtist :one
|
||||||
SELECT
|
SELECT
|
||||||
l.*
|
l.*
|
||||||
FROM listens l
|
FROM listens l
|
||||||
JOIN tracks_with_title t ON l.track_id = t.id
|
JOIN tracks_with_title t ON l.track_id = t.id
|
||||||
JOIN artist_tracks at ON t.id = at.track_id
|
JOIN artist_tracks at ON t.id = at.track_id
|
||||||
WHERE at.artist_id = $1
|
WHERE at.artist_id = $1
|
||||||
ORDER BY l.listened_at ASC
|
ORDER BY l.listened_at ASC
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: GetLastListensFromReleasePaginated :many
|
-- name: GetLastListensFromReleasePaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
l.*,
|
l.*,
|
||||||
t.title AS track_title,
|
t.title AS track_title,
|
||||||
t.release_id AS release_id,
|
t.release_id AS release_id,
|
||||||
|
|
@ -53,7 +53,7 @@ ORDER BY l.listened_at DESC
|
||||||
LIMIT $3 OFFSET $4;
|
LIMIT $3 OFFSET $4;
|
||||||
|
|
||||||
-- name: GetFirstListenFromRelease :one
|
-- name: GetFirstListenFromRelease :one
|
||||||
SELECT
|
SELECT
|
||||||
l.*
|
l.*
|
||||||
FROM listens l
|
FROM listens l
|
||||||
JOIN tracks t ON l.track_id = t.id
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
|
@ -62,7 +62,7 @@ ORDER BY l.listened_at ASC
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: GetLastListensFromTrackPaginated :many
|
-- name: GetLastListensFromTrackPaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
l.*,
|
l.*,
|
||||||
t.title AS track_title,
|
t.title AS track_title,
|
||||||
t.release_id AS release_id,
|
t.release_id AS release_id,
|
||||||
|
|
@ -75,7 +75,7 @@ ORDER BY l.listened_at DESC
|
||||||
LIMIT $3 OFFSET $4;
|
LIMIT $3 OFFSET $4;
|
||||||
|
|
||||||
-- name: GetFirstListenFromTrack :one
|
-- name: GetFirstListenFromTrack :one
|
||||||
SELECT
|
SELECT
|
||||||
l.*
|
l.*
|
||||||
FROM listens l
|
FROM listens l
|
||||||
JOIN tracks t ON l.track_id = t.id
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
|
@ -83,6 +83,13 @@ WHERE t.id = $1
|
||||||
ORDER BY l.listened_at ASC
|
ORDER BY l.listened_at ASC
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetFirstListen :one
|
||||||
|
SELECT
|
||||||
|
*
|
||||||
|
FROM listens
|
||||||
|
ORDER BY listened_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: CountListens :one
|
-- name: CountListens :one
|
||||||
SELECT COUNT(*) AS total_count
|
SELECT COUNT(*) AS total_count
|
||||||
FROM listens l
|
FROM listens l
|
||||||
|
|
@ -137,90 +144,51 @@ WHERE l.listened_at BETWEEN $1 AND $2
|
||||||
AND t.id = $3;
|
AND t.id = $3;
|
||||||
|
|
||||||
-- name: ListenActivity :many
|
-- name: ListenActivity :many
|
||||||
WITH buckets AS (
|
SELECT
|
||||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||||
),
|
COUNT(*) AS listen_count
|
||||||
bucketed_listens AS (
|
FROM listens
|
||||||
SELECT
|
WHERE listened_at >= $2
|
||||||
b.bucket_start,
|
AND listened_at < $3
|
||||||
COUNT(l.listened_at) AS listen_count
|
GROUP BY day
|
||||||
FROM buckets b
|
ORDER BY day;
|
||||||
LEFT JOIN listens l
|
|
||||||
ON l.listened_at >= b.bucket_start
|
|
||||||
AND l.listened_at < b.bucket_start + $3::interval
|
|
||||||
GROUP BY b.bucket_start
|
|
||||||
ORDER BY b.bucket_start
|
|
||||||
)
|
|
||||||
SELECT * FROM bucketed_listens;
|
|
||||||
|
|
||||||
-- name: ListenActivityForArtist :many
|
-- name: ListenActivityForArtist :many
|
||||||
WITH buckets AS (
|
SELECT
|
||||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||||
),
|
COUNT(*) AS listen_count
|
||||||
filtered_listens AS (
|
FROM listens l
|
||||||
SELECT l.*
|
JOIN tracks t ON l.track_id = t.id
|
||||||
FROM listens l
|
JOIN artist_tracks at ON t.id = at.track_id
|
||||||
JOIN artist_tracks t ON l.track_id = t.track_id
|
WHERE l.listened_at >= $2
|
||||||
WHERE t.artist_id = $4
|
AND l.listened_at < $3
|
||||||
),
|
AND at.artist_id = $4
|
||||||
bucketed_listens AS (
|
GROUP BY day
|
||||||
SELECT
|
ORDER BY day;
|
||||||
b.bucket_start,
|
|
||||||
COUNT(l.listened_at) AS listen_count
|
|
||||||
FROM buckets b
|
|
||||||
LEFT JOIN filtered_listens l
|
|
||||||
ON l.listened_at >= b.bucket_start
|
|
||||||
AND l.listened_at < b.bucket_start + $3::interval
|
|
||||||
GROUP BY b.bucket_start
|
|
||||||
ORDER BY b.bucket_start
|
|
||||||
)
|
|
||||||
SELECT * FROM bucketed_listens;
|
|
||||||
|
|
||||||
-- name: ListenActivityForRelease :many
|
-- name: ListenActivityForRelease :many
|
||||||
WITH buckets AS (
|
SELECT
|
||||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||||
),
|
COUNT(*) AS listen_count
|
||||||
filtered_listens AS (
|
FROM listens l
|
||||||
SELECT l.*
|
JOIN tracks t ON l.track_id = t.id
|
||||||
FROM listens l
|
WHERE l.listened_at >= $2
|
||||||
JOIN tracks t ON l.track_id = t.id
|
AND l.listened_at < $3
|
||||||
WHERE t.release_id = $4
|
AND t.release_id = $4
|
||||||
),
|
GROUP BY day
|
||||||
bucketed_listens AS (
|
ORDER BY day;
|
||||||
SELECT
|
|
||||||
b.bucket_start,
|
|
||||||
COUNT(l.listened_at) AS listen_count
|
|
||||||
FROM buckets b
|
|
||||||
LEFT JOIN filtered_listens l
|
|
||||||
ON l.listened_at >= b.bucket_start
|
|
||||||
AND l.listened_at < b.bucket_start + $3::interval
|
|
||||||
GROUP BY b.bucket_start
|
|
||||||
ORDER BY b.bucket_start
|
|
||||||
)
|
|
||||||
SELECT * FROM bucketed_listens;
|
|
||||||
|
|
||||||
-- name: ListenActivityForTrack :many
|
-- name: ListenActivityForTrack :many
|
||||||
WITH buckets AS (
|
SELECT
|
||||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||||
),
|
COUNT(*) AS listen_count
|
||||||
filtered_listens AS (
|
FROM listens l
|
||||||
SELECT l.*
|
JOIN tracks t ON l.track_id = t.id
|
||||||
FROM listens l
|
WHERE l.listened_at >= $2
|
||||||
JOIN tracks t ON l.track_id = t.id
|
AND l.listened_at < $3
|
||||||
WHERE t.id = $4
|
AND t.id = $4
|
||||||
),
|
GROUP BY day
|
||||||
bucketed_listens AS (
|
ORDER BY day;
|
||||||
SELECT
|
|
||||||
b.bucket_start,
|
|
||||||
COUNT(l.listened_at) AS listen_count
|
|
||||||
FROM buckets b
|
|
||||||
LEFT JOIN filtered_listens l
|
|
||||||
ON l.listened_at >= b.bucket_start
|
|
||||||
AND l.listened_at < b.bucket_start + $3::interval
|
|
||||||
GROUP BY b.bucket_start
|
|
||||||
ORDER BY b.bucket_start
|
|
||||||
)
|
|
||||||
SELECT * FROM bucketed_listens;
|
|
||||||
|
|
||||||
-- name: UpdateTrackIdForListens :exec
|
-- name: UpdateTrackIdForListens :exec
|
||||||
UPDATE listens SET track_id = $2
|
UPDATE listens SET track_id = $2
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gabehf/koito/internal/db"
|
"github.com/gabehf/koito/internal/db"
|
||||||
"github.com/gabehf/koito/internal/logger"
|
"github.com/gabehf/koito/internal/logger"
|
||||||
|
|
@ -19,7 +20,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
|
||||||
|
|
||||||
rangeStr := r.URL.Query().Get("range")
|
rangeStr := r.URL.Query().Get("range")
|
||||||
_range, err := strconv.Atoi(rangeStr)
|
_range, err := strconv.Atoi(rangeStr)
|
||||||
if err != nil {
|
if err != nil && rangeStr != "" {
|
||||||
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid range parameter")
|
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid range parameter")
|
||||||
utils.WriteError(w, "invalid range parameter", http.StatusBadRequest)
|
utils.WriteError(w, "invalid range parameter", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -27,7 +28,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
|
||||||
|
|
||||||
monthStr := r.URL.Query().Get("month")
|
monthStr := r.URL.Query().Get("month")
|
||||||
month, err := strconv.Atoi(monthStr)
|
month, err := strconv.Atoi(monthStr)
|
||||||
if err != nil {
|
if err != nil && monthStr != "" {
|
||||||
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid month parameter")
|
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid month parameter")
|
||||||
utils.WriteError(w, "invalid month parameter", http.StatusBadRequest)
|
utils.WriteError(w, "invalid month parameter", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -35,7 +36,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
|
||||||
|
|
||||||
yearStr := r.URL.Query().Get("year")
|
yearStr := r.URL.Query().Get("year")
|
||||||
year, err := strconv.Atoi(yearStr)
|
year, err := strconv.Atoi(yearStr)
|
||||||
if err != nil {
|
if err != nil && yearStr != "" {
|
||||||
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid year parameter")
|
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid year parameter")
|
||||||
utils.WriteError(w, "invalid year parameter", http.StatusBadRequest)
|
utils.WriteError(w, "invalid year parameter", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -43,7 +44,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
|
||||||
|
|
||||||
artistIdStr := r.URL.Query().Get("artist_id")
|
artistIdStr := r.URL.Query().Get("artist_id")
|
||||||
artistId, err := strconv.Atoi(artistIdStr)
|
artistId, err := strconv.Atoi(artistIdStr)
|
||||||
if err != nil {
|
if err != nil && artistIdStr != "" {
|
||||||
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid artist ID parameter")
|
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid artist ID parameter")
|
||||||
utils.WriteError(w, "invalid artist ID parameter", http.StatusBadRequest)
|
utils.WriteError(w, "invalid artist ID parameter", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -51,7 +52,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
|
||||||
|
|
||||||
albumIdStr := r.URL.Query().Get("album_id")
|
albumIdStr := r.URL.Query().Get("album_id")
|
||||||
albumId, err := strconv.Atoi(albumIdStr)
|
albumId, err := strconv.Atoi(albumIdStr)
|
||||||
if err != nil {
|
if err != nil && albumIdStr != "" {
|
||||||
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid album ID parameter")
|
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid album ID parameter")
|
||||||
utils.WriteError(w, "invalid album ID parameter", http.StatusBadRequest)
|
utils.WriteError(w, "invalid album ID parameter", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -59,7 +60,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
|
||||||
|
|
||||||
trackIdStr := r.URL.Query().Get("track_id")
|
trackIdStr := r.URL.Query().Get("track_id")
|
||||||
trackId, err := strconv.Atoi(trackIdStr)
|
trackId, err := strconv.Atoi(trackIdStr)
|
||||||
if err != nil {
|
if err != nil && trackIdStr != "" {
|
||||||
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid track ID parameter")
|
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid track ID parameter")
|
||||||
utils.WriteError(w, "invalid track ID parameter", http.StatusBadRequest)
|
utils.WriteError(w, "invalid track ID parameter", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -85,11 +86,17 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
|
||||||
Range: _range,
|
Range: _range,
|
||||||
Month: month,
|
Month: month,
|
||||||
Year: year,
|
Year: year,
|
||||||
|
Timezone: parseTZ(r),
|
||||||
AlbumID: int32(albumId),
|
AlbumID: int32(albumId),
|
||||||
ArtistID: int32(artistId),
|
ArtistID: int32(artistId),
|
||||||
TrackID: int32(trackId),
|
TrackID: int32(trackId),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.ToLower(opts.Timezone.String()) == "local" {
|
||||||
|
opts.Timezone, _ = time.LoadLocation("UTC")
|
||||||
|
l.Warn().Msg("GetListenActivityHandler: Timezone is unset, using UTC")
|
||||||
|
}
|
||||||
|
|
||||||
l.Debug().Msgf("GetListenActivityHandler: Retrieving listen activity with options: %+v", opts)
|
l.Debug().Msgf("GetListenActivityHandler: Retrieving listen activity with options: %+v", opts)
|
||||||
|
|
||||||
activity, err := store.GetListenActivity(ctx, opts)
|
activity, err := store.GetListenActivity(ctx, opts)
|
||||||
|
|
@ -99,7 +106,51 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activity = fillMissingActivity(activity, opts)
|
||||||
|
|
||||||
l.Debug().Msg("GetListenActivityHandler: Successfully retrieved listen activity")
|
l.Debug().Msg("GetListenActivityHandler: Successfully retrieved listen activity")
|
||||||
utils.WriteJSON(w, http.StatusOK, activity)
|
utils.WriteJSON(w, http.StatusOK, activity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ngl i hate this
|
||||||
|
func fillMissingActivity(
|
||||||
|
items []db.ListenActivityItem,
|
||||||
|
opts db.ListenActivityOpts,
|
||||||
|
) []db.ListenActivityItem {
|
||||||
|
from, to := db.ListenActivityOptsToTimes(opts)
|
||||||
|
|
||||||
|
existing := make(map[string]int64, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
existing[item.Start.Format("2006-01-02")] = item.Listens
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []db.ListenActivityItem
|
||||||
|
|
||||||
|
for t := from; t.Before(to); t = addStep(t, opts.Step) {
|
||||||
|
listens := int64(0)
|
||||||
|
if v, ok := existing[t.Format("2006-01-02")]; ok {
|
||||||
|
listens = v
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, db.ListenActivityItem{
|
||||||
|
Start: t,
|
||||||
|
Listens: int64(listens),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func addStep(t time.Time, step db.StepInterval) time.Time {
|
||||||
|
switch step {
|
||||||
|
case db.StepDay:
|
||||||
|
return t.AddDate(0, 0, 1)
|
||||||
|
case db.StepWeek:
|
||||||
|
return t.AddDate(0, 0, 7)
|
||||||
|
case db.StepMonth:
|
||||||
|
return t.AddDate(0, 1, 0)
|
||||||
|
default:
|
||||||
|
return t.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gabehf/koito/internal/db"
|
"github.com/gabehf/koito/internal/db"
|
||||||
"github.com/gabehf/koito/internal/logger"
|
"github.com/gabehf/koito/internal/logger"
|
||||||
|
|
@ -100,5 +101,23 @@ func TimeframeFromRequest(r *http.Request) db.Timeframe {
|
||||||
Week: parseInt("week"),
|
Week: parseInt("week"),
|
||||||
FromUnix: parseInt64("from"),
|
FromUnix: parseInt64("from"),
|
||||||
ToUnix: parseInt64("to"),
|
ToUnix: parseInt64("to"),
|
||||||
|
Timezone: parseTZ(r),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseTZ(r *http.Request) *time.Location {
|
||||||
|
|
||||||
|
if tz := r.URL.Query().Get("tz"); tz != "" {
|
||||||
|
if loc, err := time.LoadLocation(tz); err == nil {
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c, err := r.Cookie("tz"); err == nil {
|
||||||
|
if loc, err := time.LoadLocation(c.Value); err == nil {
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Now().Location()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,7 @@ type ListenActivityOpts struct {
|
||||||
Range int
|
Range int
|
||||||
Month int
|
Month int
|
||||||
Year int
|
Year int
|
||||||
|
Timezone *time.Location
|
||||||
AlbumID int32
|
AlbumID int32
|
||||||
ArtistID int32
|
ArtistID int32
|
||||||
TrackID int32
|
TrackID int32
|
||||||
|
|
|
||||||
|
|
@ -58,16 +58,20 @@ const (
|
||||||
// If opts.Year (or opts.Year + opts.Month) is provided, start and end will simply by the start and end times of that year/month.
|
// If opts.Year (or opts.Year + opts.Month) is provided, start and end will simply by the start and end times of that year/month.
|
||||||
func ListenActivityOptsToTimes(opts ListenActivityOpts) (start, end time.Time) {
|
func ListenActivityOptsToTimes(opts ListenActivityOpts) (start, end time.Time) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
loc := opts.Timezone
|
||||||
|
if loc == nil {
|
||||||
|
loc, _ = time.LoadLocation("UTC")
|
||||||
|
}
|
||||||
|
|
||||||
// If Year (and optionally Month) are specified, use calendar boundaries
|
// If Year (and optionally Month) are specified, use calendar boundaries
|
||||||
if opts.Year != 0 {
|
if opts.Year != 0 {
|
||||||
if opts.Month != 0 {
|
if opts.Month != 0 {
|
||||||
// Specific month of a specific year
|
// Specific month of a specific year
|
||||||
start = time.Date(opts.Year, time.Month(opts.Month), 1, 0, 0, 0, 0, now.Location())
|
start = time.Date(opts.Year, time.Month(opts.Month), 1, 0, 0, 0, 0, loc)
|
||||||
end = start.AddDate(0, 1, 0).Add(-time.Nanosecond)
|
end = start.AddDate(0, 1, 0).Add(-time.Nanosecond)
|
||||||
} else {
|
} else {
|
||||||
// Whole year
|
// Whole year
|
||||||
start = time.Date(opts.Year, 1, 1, 0, 0, 0, 0, now.Location())
|
start = time.Date(opts.Year, 1, 1, 0, 0, 0, 0, loc)
|
||||||
end = start.AddDate(1, 0, 0).Add(-time.Nanosecond)
|
end = start.AddDate(1, 0, 0).Add(-time.Nanosecond)
|
||||||
}
|
}
|
||||||
return start, end
|
return start, end
|
||||||
|
|
@ -79,30 +83,30 @@ func ListenActivityOptsToTimes(opts ListenActivityOpts) (start, end time.Time) {
|
||||||
// Determine step and align accordingly
|
// Determine step and align accordingly
|
||||||
switch opts.Step {
|
switch opts.Step {
|
||||||
case StepDay:
|
case StepDay:
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||||
start = today.AddDate(0, 0, -opts.Range)
|
start = today.AddDate(0, 0, -opts.Range)
|
||||||
end = today.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
end = today.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||||
|
|
||||||
case StepWeek:
|
case StepWeek:
|
||||||
// Align to most recent Sunday
|
// Align to most recent Sunday
|
||||||
weekday := int(now.Weekday()) // Sunday = 0
|
weekday := int(now.Weekday()) // Sunday = 0
|
||||||
startOfThisWeek := time.Date(now.Year(), now.Month(), now.Day()-weekday, 0, 0, 0, 0, now.Location())
|
startOfThisWeek := time.Date(now.Year(), now.Month(), now.Day()-weekday, 0, 0, 0, 0, loc)
|
||||||
start = startOfThisWeek.AddDate(0, 0, -7*opts.Range)
|
start = startOfThisWeek.AddDate(0, 0, -7*opts.Range)
|
||||||
end = startOfThisWeek.AddDate(0, 0, 7).Add(-time.Nanosecond)
|
end = startOfThisWeek.AddDate(0, 0, 7).Add(-time.Nanosecond)
|
||||||
|
|
||||||
case StepMonth:
|
case StepMonth:
|
||||||
firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
|
||||||
start = firstOfThisMonth.AddDate(0, -opts.Range, 0)
|
start = firstOfThisMonth.AddDate(0, -opts.Range, 0)
|
||||||
end = firstOfThisMonth.AddDate(0, 1, 0).Add(-time.Nanosecond)
|
end = firstOfThisMonth.AddDate(0, 1, 0).Add(-time.Nanosecond)
|
||||||
|
|
||||||
case StepYear:
|
case StepYear:
|
||||||
firstOfThisYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
|
firstOfThisYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, loc)
|
||||||
start = firstOfThisYear.AddDate(-opts.Range, 0, 0)
|
start = firstOfThisYear.AddDate(-opts.Range, 0, 0)
|
||||||
end = firstOfThisYear.AddDate(1, 0, 0).Add(-time.Nanosecond)
|
end = firstOfThisYear.AddDate(1, 0, 0).Add(-time.Nanosecond)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Default to daily
|
// Default to daily
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||||
start = today.AddDate(0, 0, -opts.Range)
|
start = today.AddDate(0, 0, -opts.Range)
|
||||||
end = today.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
end = today.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,10 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
|
||||||
l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v for release group %d",
|
l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v for release group %d",
|
||||||
opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"), opts.AlbumID)
|
opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"), opts.AlbumID)
|
||||||
rows, err := d.q.ListenActivityForRelease(ctx, repository.ListenActivityForReleaseParams{
|
rows, err := d.q.ListenActivityForRelease(ctx, repository.ListenActivityForReleaseParams{
|
||||||
Column1: t1,
|
Column1: opts.Timezone.String(),
|
||||||
Column2: t2,
|
ListenedAt: t1,
|
||||||
Column3: stepToInterval(opts.Step),
|
ListenedAt_2: t2,
|
||||||
ReleaseID: opts.AlbumID,
|
ReleaseID: opts.AlbumID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GetListenActivity: ListenActivityForRelease: %w", err)
|
return nil, fmt.Errorf("GetListenActivity: ListenActivityForRelease: %w", err)
|
||||||
|
|
@ -36,7 +36,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
|
||||||
listenActivity = make([]db.ListenActivityItem, len(rows))
|
listenActivity = make([]db.ListenActivityItem, len(rows))
|
||||||
for i, row := range rows {
|
for i, row := range rows {
|
||||||
t := db.ListenActivityItem{
|
t := db.ListenActivityItem{
|
||||||
Start: row.BucketStart,
|
Start: row.Day.Time,
|
||||||
Listens: row.ListenCount,
|
Listens: row.ListenCount,
|
||||||
}
|
}
|
||||||
listenActivity[i] = t
|
listenActivity[i] = t
|
||||||
|
|
@ -46,10 +46,10 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
|
||||||
l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v for artist %d",
|
l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v for artist %d",
|
||||||
opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"), opts.ArtistID)
|
opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"), opts.ArtistID)
|
||||||
rows, err := d.q.ListenActivityForArtist(ctx, repository.ListenActivityForArtistParams{
|
rows, err := d.q.ListenActivityForArtist(ctx, repository.ListenActivityForArtistParams{
|
||||||
Column1: t1,
|
Column1: opts.Timezone.String(),
|
||||||
Column2: t2,
|
ListenedAt: t1,
|
||||||
Column3: stepToInterval(opts.Step),
|
ListenedAt_2: t2,
|
||||||
ArtistID: opts.ArtistID,
|
ArtistID: opts.ArtistID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GetListenActivity: ListenActivityForArtist: %w", err)
|
return nil, fmt.Errorf("GetListenActivity: ListenActivityForArtist: %w", err)
|
||||||
|
|
@ -57,7 +57,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
|
||||||
listenActivity = make([]db.ListenActivityItem, len(rows))
|
listenActivity = make([]db.ListenActivityItem, len(rows))
|
||||||
for i, row := range rows {
|
for i, row := range rows {
|
||||||
t := db.ListenActivityItem{
|
t := db.ListenActivityItem{
|
||||||
Start: row.BucketStart,
|
Start: row.Day.Time,
|
||||||
Listens: row.ListenCount,
|
Listens: row.ListenCount,
|
||||||
}
|
}
|
||||||
listenActivity[i] = t
|
listenActivity[i] = t
|
||||||
|
|
@ -67,10 +67,10 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
|
||||||
l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v for track %d",
|
l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v for track %d",
|
||||||
opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"), opts.TrackID)
|
opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"), opts.TrackID)
|
||||||
rows, err := d.q.ListenActivityForTrack(ctx, repository.ListenActivityForTrackParams{
|
rows, err := d.q.ListenActivityForTrack(ctx, repository.ListenActivityForTrackParams{
|
||||||
Column1: t1,
|
Column1: opts.Timezone.String(),
|
||||||
Column2: t2,
|
ListenedAt: t1,
|
||||||
Column3: stepToInterval(opts.Step),
|
ListenedAt_2: t2,
|
||||||
ID: opts.TrackID,
|
ID: opts.TrackID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GetListenActivity: ListenActivityForTrack: %w", err)
|
return nil, fmt.Errorf("GetListenActivity: ListenActivityForTrack: %w", err)
|
||||||
|
|
@ -78,7 +78,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
|
||||||
listenActivity = make([]db.ListenActivityItem, len(rows))
|
listenActivity = make([]db.ListenActivityItem, len(rows))
|
||||||
for i, row := range rows {
|
for i, row := range rows {
|
||||||
t := db.ListenActivityItem{
|
t := db.ListenActivityItem{
|
||||||
Start: row.BucketStart,
|
Start: row.Day.Time,
|
||||||
Listens: row.ListenCount,
|
Listens: row.ListenCount,
|
||||||
}
|
}
|
||||||
listenActivity[i] = t
|
listenActivity[i] = t
|
||||||
|
|
@ -88,9 +88,9 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
|
||||||
l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v",
|
l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v",
|
||||||
opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"))
|
opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"))
|
||||||
rows, err := d.q.ListenActivity(ctx, repository.ListenActivityParams{
|
rows, err := d.q.ListenActivity(ctx, repository.ListenActivityParams{
|
||||||
Column1: t1,
|
Column1: opts.Timezone.String(),
|
||||||
Column2: t2,
|
ListenedAt: t1,
|
||||||
Column3: stepToInterval(opts.Step),
|
ListenedAt_2: t2,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GetListenActivity: ListenActivity: %w", err)
|
return nil, fmt.Errorf("GetListenActivity: ListenActivity: %w", err)
|
||||||
|
|
@ -98,7 +98,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
|
||||||
listenActivity = make([]db.ListenActivityItem, len(rows))
|
listenActivity = make([]db.ListenActivityItem, len(rows))
|
||||||
for i, row := range rows {
|
for i, row := range rows {
|
||||||
t := db.ListenActivityItem{
|
t := db.ListenActivityItem{
|
||||||
Start: row.BucketStart,
|
Start: row.Day.Time,
|
||||||
Listens: row.ListenCount,
|
Listens: row.ListenCount,
|
||||||
}
|
}
|
||||||
listenActivity[i] = t
|
listenActivity[i] = t
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,8 @@ func TestListenActivity(t *testing.T) {
|
||||||
// Test for opts.Step = db.StepDay
|
// Test for opts.Step = db.StepDay
|
||||||
activity, err := store.GetListenActivity(ctx, db.ListenActivityOpts{Step: db.StepDay})
|
activity, err := store.GetListenActivity(ctx, db.ListenActivityOpts{Step: db.StepDay})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, activity, db.DefaultRange)
|
require.Len(t, activity, 3)
|
||||||
assert.Equal(t, []int64{0, 0, 0, 2, 0, 0, 0, 0, 0, 2, 2, 0}, flattenListenCounts(activity))
|
assert.Equal(t, []int64{2, 2, 2}, flattenListenCounts(activity))
|
||||||
|
|
||||||
// Truncate listens table and insert specific dates for testing opts.Step = db.StepMonth
|
// Truncate listens table and insert specific dates for testing opts.Step = db.StepMonth
|
||||||
err = store.Exec(context.Background(), `TRUNCATE TABLE listens`)
|
err = store.Exec(context.Background(), `TRUNCATE TABLE listens`)
|
||||||
|
|
@ -126,8 +126,8 @@ func TestListenActivity(t *testing.T) {
|
||||||
|
|
||||||
activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{Step: db.StepYear})
|
activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{Step: db.StepYear})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, activity, db.DefaultRange)
|
require.Len(t, activity, 3)
|
||||||
assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 0}, flattenListenCounts(activity))
|
assert.Equal(t, []int64{1, 1, 2}, flattenListenCounts(activity))
|
||||||
// Truncate and insert data for a specific month/year
|
// Truncate and insert data for a specific month/year
|
||||||
err = store.Exec(context.Background(), `TRUNCATE TABLE listens RESTART IDENTITY`)
|
err = store.Exec(context.Background(), `TRUNCATE TABLE listens RESTART IDENTITY`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -144,10 +144,10 @@ func TestListenActivity(t *testing.T) {
|
||||||
Year: 2024,
|
Year: 2024,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, activity, 31) // number of days in march
|
require.Len(t, activity, 2) // number of days in march
|
||||||
t.Log(activity)
|
t.Log(activity)
|
||||||
assert.EqualValues(t, 1, activity[9].Listens)
|
assert.EqualValues(t, 1, activity[0].Listens)
|
||||||
assert.EqualValues(t, 1, activity[19].Listens)
|
assert.EqualValues(t, 1, activity[1].Listens)
|
||||||
|
|
||||||
// Truncate and insert listens associated with two different albums
|
// Truncate and insert listens associated with two different albums
|
||||||
err = store.Exec(context.Background(), `TRUNCATE TABLE listens RESTART IDENTITY`)
|
err = store.Exec(context.Background(), `TRUNCATE TABLE listens RESTART IDENTITY`)
|
||||||
|
|
@ -164,53 +164,29 @@ func TestListenActivity(t *testing.T) {
|
||||||
AlbumID: 1, // Track 1 only
|
AlbumID: 1, // Track 1 only
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, activity, db.DefaultRange)
|
require.Len(t, activity, 2)
|
||||||
assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0}, flattenListenCounts(activity))
|
assert.Equal(t, []int64{1, 1}, flattenListenCounts(activity))
|
||||||
|
|
||||||
activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
|
activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
|
||||||
Step: db.StepDay,
|
Step: db.StepDay,
|
||||||
TrackID: 1, // Track 1 only
|
TrackID: 1, // Track 1 only
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, activity, db.DefaultRange)
|
require.Len(t, activity, 2)
|
||||||
assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0}, flattenListenCounts(activity))
|
assert.Equal(t, []int64{1, 1}, flattenListenCounts(activity))
|
||||||
|
|
||||||
activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
|
activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
|
||||||
Step: db.StepDay,
|
Step: db.StepDay,
|
||||||
ArtistID: 2, // Should only include listens to Track 2
|
ArtistID: 2, // Should only include listens to Track 2
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, activity, db.DefaultRange)
|
require.Len(t, activity, 1)
|
||||||
assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0}, flattenListenCounts(activity))
|
assert.Equal(t, []int64{1}, flattenListenCounts(activity))
|
||||||
|
|
||||||
// month without year is disallowed
|
// month without year is disallowed
|
||||||
_, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
|
_, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
|
||||||
Step: db.StepDay,
|
Step: db.StepDay,
|
||||||
Month: 5,
|
Month: 5,
|
||||||
})
|
})
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
||||||
// invalid options
|
|
||||||
_, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
|
|
||||||
Year: -10,
|
|
||||||
})
|
|
||||||
require.Error(t, err)
|
|
||||||
_, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
|
|
||||||
Year: 2025,
|
|
||||||
Month: -10,
|
|
||||||
})
|
|
||||||
require.Error(t, err)
|
|
||||||
_, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
|
|
||||||
Range: -1,
|
|
||||||
})
|
|
||||||
require.Error(t, err)
|
|
||||||
_, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
|
|
||||||
AlbumID: -1,
|
|
||||||
})
|
|
||||||
require.Error(t, err)
|
|
||||||
_, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
|
|
||||||
ArtistID: -1,
|
|
||||||
})
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,15 @@ type Timeframe struct {
|
||||||
ToUnix int64
|
ToUnix int64
|
||||||
From time.Time
|
From time.Time
|
||||||
To time.Time
|
To time.Time
|
||||||
|
Timezone *time.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
func TimeframeToTimeRange(tf Timeframe) (t1, t2 time.Time) {
|
func TimeframeToTimeRange(tf Timeframe) (t1, t2 time.Time) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
loc := now.Location()
|
loc := tf.Timezone
|
||||||
|
if loc == nil {
|
||||||
|
loc, _ = time.LoadLocation("UTC")
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// 1. Explicit From / To (time.Time) — highest precedence
|
// 1. Explicit From / To (time.Time) — highest precedence
|
||||||
|
|
|
||||||
|
|
@ -190,12 +190,32 @@ func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) erro
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getFirstListen = `-- name: GetFirstListen :one
|
||||||
|
SELECT
|
||||||
|
track_id, listened_at, client, user_id
|
||||||
|
FROM listens
|
||||||
|
ORDER BY listened_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetFirstListen(ctx context.Context) (Listen, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getFirstListen)
|
||||||
|
var i Listen
|
||||||
|
err := row.Scan(
|
||||||
|
&i.TrackID,
|
||||||
|
&i.ListenedAt,
|
||||||
|
&i.Client,
|
||||||
|
&i.UserID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getFirstListenFromArtist = `-- name: GetFirstListenFromArtist :one
|
const getFirstListenFromArtist = `-- name: GetFirstListenFromArtist :one
|
||||||
SELECT
|
SELECT
|
||||||
l.track_id, l.listened_at, l.client, l.user_id
|
l.track_id, l.listened_at, l.client, l.user_id
|
||||||
FROM listens l
|
FROM listens l
|
||||||
JOIN tracks_with_title t ON l.track_id = t.id
|
JOIN tracks_with_title t ON l.track_id = t.id
|
||||||
JOIN artist_tracks at ON t.id = at.track_id
|
JOIN artist_tracks at ON t.id = at.track_id
|
||||||
WHERE at.artist_id = $1
|
WHERE at.artist_id = $1
|
||||||
ORDER BY l.listened_at ASC
|
ORDER BY l.listened_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|
@ -214,7 +234,7 @@ func (q *Queries) GetFirstListenFromArtist(ctx context.Context, artistID int32)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFirstListenFromRelease = `-- name: GetFirstListenFromRelease :one
|
const getFirstListenFromRelease = `-- name: GetFirstListenFromRelease :one
|
||||||
SELECT
|
SELECT
|
||||||
l.track_id, l.listened_at, l.client, l.user_id
|
l.track_id, l.listened_at, l.client, l.user_id
|
||||||
FROM listens l
|
FROM listens l
|
||||||
JOIN tracks t ON l.track_id = t.id
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
|
@ -236,7 +256,7 @@ func (q *Queries) GetFirstListenFromRelease(ctx context.Context, releaseID int32
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFirstListenFromTrack = `-- name: GetFirstListenFromTrack :one
|
const getFirstListenFromTrack = `-- name: GetFirstListenFromTrack :one
|
||||||
SELECT
|
SELECT
|
||||||
l.track_id, l.listened_at, l.client, l.user_id
|
l.track_id, l.listened_at, l.client, l.user_id
|
||||||
FROM listens l
|
FROM listens l
|
||||||
JOIN tracks t ON l.track_id = t.id
|
JOIN tracks t ON l.track_id = t.id
|
||||||
|
|
@ -258,14 +278,14 @@ func (q *Queries) GetFirstListenFromTrack(ctx context.Context, id int32) (Listen
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLastListensFromArtistPaginated = `-- name: GetLastListensFromArtistPaginated :many
|
const getLastListensFromArtistPaginated = `-- name: GetLastListensFromArtistPaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
l.track_id, l.listened_at, l.client, l.user_id,
|
l.track_id, l.listened_at, l.client, l.user_id,
|
||||||
t.title AS track_title,
|
t.title AS track_title,
|
||||||
t.release_id AS release_id,
|
t.release_id AS release_id,
|
||||||
get_artists_for_track(t.id) AS artists
|
get_artists_for_track(t.id) AS artists
|
||||||
FROM listens l
|
FROM listens l
|
||||||
JOIN tracks_with_title t ON l.track_id = t.id
|
JOIN tracks_with_title t ON l.track_id = t.id
|
||||||
JOIN artist_tracks at ON t.id = at.track_id
|
JOIN artist_tracks at ON t.id = at.track_id
|
||||||
WHERE at.artist_id = $5
|
WHERE at.artist_id = $5
|
||||||
AND l.listened_at BETWEEN $1 AND $2
|
AND l.listened_at BETWEEN $1 AND $2
|
||||||
ORDER BY l.listened_at DESC
|
ORDER BY l.listened_at DESC
|
||||||
|
|
@ -325,7 +345,7 @@ func (q *Queries) GetLastListensFromArtistPaginated(ctx context.Context, arg Get
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLastListensFromReleasePaginated = `-- name: GetLastListensFromReleasePaginated :many
|
const getLastListensFromReleasePaginated = `-- name: GetLastListensFromReleasePaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
l.track_id, l.listened_at, l.client, l.user_id,
|
l.track_id, l.listened_at, l.client, l.user_id,
|
||||||
t.title AS track_title,
|
t.title AS track_title,
|
||||||
t.release_id AS release_id,
|
t.release_id AS release_id,
|
||||||
|
|
@ -391,7 +411,7 @@ func (q *Queries) GetLastListensFromReleasePaginated(ctx context.Context, arg Ge
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLastListensFromTrackPaginated = `-- name: GetLastListensFromTrackPaginated :many
|
const getLastListensFromTrackPaginated = `-- name: GetLastListensFromTrackPaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
l.track_id, l.listened_at, l.client, l.user_id,
|
l.track_id, l.listened_at, l.client, l.user_id,
|
||||||
t.title AS track_title,
|
t.title AS track_title,
|
||||||
t.release_id AS release_id,
|
t.release_id AS release_id,
|
||||||
|
|
@ -457,7 +477,7 @@ func (q *Queries) GetLastListensFromTrackPaginated(ctx context.Context, arg GetL
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLastListensPaginated = `-- name: GetLastListensPaginated :many
|
const getLastListensPaginated = `-- name: GetLastListensPaginated :many
|
||||||
SELECT
|
SELECT
|
||||||
l.track_id, l.listened_at, l.client, l.user_id,
|
l.track_id, l.listened_at, l.client, l.user_id,
|
||||||
t.title AS track_title,
|
t.title AS track_title,
|
||||||
t.release_id AS release_id,
|
t.release_id AS release_id,
|
||||||
|
|
@ -675,36 +695,29 @@ func (q *Queries) InsertListen(ctx context.Context, arg InsertListenParams) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
const listenActivity = `-- name: ListenActivity :many
|
const listenActivity = `-- name: ListenActivity :many
|
||||||
WITH buckets AS (
|
SELECT
|
||||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||||
),
|
COUNT(*) AS listen_count
|
||||||
bucketed_listens AS (
|
FROM listens
|
||||||
SELECT
|
WHERE listened_at >= $2
|
||||||
b.bucket_start,
|
AND listened_at < $3
|
||||||
COUNT(l.listened_at) AS listen_count
|
GROUP BY day
|
||||||
FROM buckets b
|
ORDER BY day
|
||||||
LEFT JOIN listens l
|
|
||||||
ON l.listened_at >= b.bucket_start
|
|
||||||
AND l.listened_at < b.bucket_start + $3::interval
|
|
||||||
GROUP BY b.bucket_start
|
|
||||||
ORDER BY b.bucket_start
|
|
||||||
)
|
|
||||||
SELECT bucket_start, listen_count FROM bucketed_listens
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListenActivityParams struct {
|
type ListenActivityParams struct {
|
||||||
Column1 time.Time
|
Column1 string
|
||||||
Column2 time.Time
|
ListenedAt time.Time
|
||||||
Column3 pgtype.Interval
|
ListenedAt_2 time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListenActivityRow struct {
|
type ListenActivityRow struct {
|
||||||
BucketStart time.Time
|
Day pgtype.Date
|
||||||
ListenCount int64
|
ListenCount int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListenActivity(ctx context.Context, arg ListenActivityParams) ([]ListenActivityRow, error) {
|
func (q *Queries) ListenActivity(ctx context.Context, arg ListenActivityParams) ([]ListenActivityRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listenActivity, arg.Column1, arg.Column2, arg.Column3)
|
rows, err := q.db.Query(ctx, listenActivity, arg.Column1, arg.ListenedAt, arg.ListenedAt_2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -712,7 +725,7 @@ func (q *Queries) ListenActivity(ctx context.Context, arg ListenActivityParams)
|
||||||
var items []ListenActivityRow
|
var items []ListenActivityRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListenActivityRow
|
var i ListenActivityRow
|
||||||
if err := rows.Scan(&i.BucketStart, &i.ListenCount); err != nil {
|
if err := rows.Scan(&i.Day, &i.ListenCount); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
|
|
@ -724,46 +737,36 @@ func (q *Queries) ListenActivity(ctx context.Context, arg ListenActivityParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
const listenActivityForArtist = `-- name: ListenActivityForArtist :many
|
const listenActivityForArtist = `-- name: ListenActivityForArtist :many
|
||||||
WITH buckets AS (
|
SELECT
|
||||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||||
),
|
COUNT(*) AS listen_count
|
||||||
filtered_listens AS (
|
FROM listens l
|
||||||
SELECT l.track_id, l.listened_at, l.client, l.user_id
|
JOIN tracks t ON l.track_id = t.id
|
||||||
FROM listens l
|
JOIN artist_tracks at ON t.id = at.track_id
|
||||||
JOIN artist_tracks t ON l.track_id = t.track_id
|
WHERE l.listened_at >= $2
|
||||||
WHERE t.artist_id = $4
|
AND l.listened_at < $3
|
||||||
),
|
AND at.artist_id = $4
|
||||||
bucketed_listens AS (
|
GROUP BY day
|
||||||
SELECT
|
ORDER BY day
|
||||||
b.bucket_start,
|
|
||||||
COUNT(l.listened_at) AS listen_count
|
|
||||||
FROM buckets b
|
|
||||||
LEFT JOIN filtered_listens l
|
|
||||||
ON l.listened_at >= b.bucket_start
|
|
||||||
AND l.listened_at < b.bucket_start + $3::interval
|
|
||||||
GROUP BY b.bucket_start
|
|
||||||
ORDER BY b.bucket_start
|
|
||||||
)
|
|
||||||
SELECT bucket_start, listen_count FROM bucketed_listens
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListenActivityForArtistParams struct {
|
type ListenActivityForArtistParams struct {
|
||||||
Column1 time.Time
|
Column1 string
|
||||||
Column2 time.Time
|
ListenedAt time.Time
|
||||||
Column3 pgtype.Interval
|
ListenedAt_2 time.Time
|
||||||
ArtistID int32
|
ArtistID int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListenActivityForArtistRow struct {
|
type ListenActivityForArtistRow struct {
|
||||||
BucketStart time.Time
|
Day pgtype.Date
|
||||||
ListenCount int64
|
ListenCount int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListenActivityForArtist(ctx context.Context, arg ListenActivityForArtistParams) ([]ListenActivityForArtistRow, error) {
|
func (q *Queries) ListenActivityForArtist(ctx context.Context, arg ListenActivityForArtistParams) ([]ListenActivityForArtistRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listenActivityForArtist,
|
rows, err := q.db.Query(ctx, listenActivityForArtist,
|
||||||
arg.Column1,
|
arg.Column1,
|
||||||
arg.Column2,
|
arg.ListenedAt,
|
||||||
arg.Column3,
|
arg.ListenedAt_2,
|
||||||
arg.ArtistID,
|
arg.ArtistID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -773,7 +776,7 @@ func (q *Queries) ListenActivityForArtist(ctx context.Context, arg ListenActivit
|
||||||
var items []ListenActivityForArtistRow
|
var items []ListenActivityForArtistRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListenActivityForArtistRow
|
var i ListenActivityForArtistRow
|
||||||
if err := rows.Scan(&i.BucketStart, &i.ListenCount); err != nil {
|
if err := rows.Scan(&i.Day, &i.ListenCount); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
|
|
@ -785,46 +788,35 @@ func (q *Queries) ListenActivityForArtist(ctx context.Context, arg ListenActivit
|
||||||
}
|
}
|
||||||
|
|
||||||
const listenActivityForRelease = `-- name: ListenActivityForRelease :many
|
const listenActivityForRelease = `-- name: ListenActivityForRelease :many
|
||||||
WITH buckets AS (
|
SELECT
|
||||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||||
),
|
COUNT(*) AS listen_count
|
||||||
filtered_listens AS (
|
FROM listens l
|
||||||
SELECT l.track_id, l.listened_at, l.client, l.user_id
|
JOIN tracks t ON l.track_id = t.id
|
||||||
FROM listens l
|
WHERE l.listened_at >= $2
|
||||||
JOIN tracks t ON l.track_id = t.id
|
AND l.listened_at < $3
|
||||||
WHERE t.release_id = $4
|
AND t.release_id = $4
|
||||||
),
|
GROUP BY day
|
||||||
bucketed_listens AS (
|
ORDER BY day
|
||||||
SELECT
|
|
||||||
b.bucket_start,
|
|
||||||
COUNT(l.listened_at) AS listen_count
|
|
||||||
FROM buckets b
|
|
||||||
LEFT JOIN filtered_listens l
|
|
||||||
ON l.listened_at >= b.bucket_start
|
|
||||||
AND l.listened_at < b.bucket_start + $3::interval
|
|
||||||
GROUP BY b.bucket_start
|
|
||||||
ORDER BY b.bucket_start
|
|
||||||
)
|
|
||||||
SELECT bucket_start, listen_count FROM bucketed_listens
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListenActivityForReleaseParams struct {
|
type ListenActivityForReleaseParams struct {
|
||||||
Column1 time.Time
|
Column1 string
|
||||||
Column2 time.Time
|
ListenedAt time.Time
|
||||||
Column3 pgtype.Interval
|
ListenedAt_2 time.Time
|
||||||
ReleaseID int32
|
ReleaseID int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListenActivityForReleaseRow struct {
|
type ListenActivityForReleaseRow struct {
|
||||||
BucketStart time.Time
|
Day pgtype.Date
|
||||||
ListenCount int64
|
ListenCount int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListenActivityForRelease(ctx context.Context, arg ListenActivityForReleaseParams) ([]ListenActivityForReleaseRow, error) {
|
func (q *Queries) ListenActivityForRelease(ctx context.Context, arg ListenActivityForReleaseParams) ([]ListenActivityForReleaseRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listenActivityForRelease,
|
rows, err := q.db.Query(ctx, listenActivityForRelease,
|
||||||
arg.Column1,
|
arg.Column1,
|
||||||
arg.Column2,
|
arg.ListenedAt,
|
||||||
arg.Column3,
|
arg.ListenedAt_2,
|
||||||
arg.ReleaseID,
|
arg.ReleaseID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -834,7 +826,7 @@ func (q *Queries) ListenActivityForRelease(ctx context.Context, arg ListenActivi
|
||||||
var items []ListenActivityForReleaseRow
|
var items []ListenActivityForReleaseRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListenActivityForReleaseRow
|
var i ListenActivityForReleaseRow
|
||||||
if err := rows.Scan(&i.BucketStart, &i.ListenCount); err != nil {
|
if err := rows.Scan(&i.Day, &i.ListenCount); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
|
|
@ -846,46 +838,35 @@ func (q *Queries) ListenActivityForRelease(ctx context.Context, arg ListenActivi
|
||||||
}
|
}
|
||||||
|
|
||||||
const listenActivityForTrack = `-- name: ListenActivityForTrack :many
|
const listenActivityForTrack = `-- name: ListenActivityForTrack :many
|
||||||
WITH buckets AS (
|
SELECT
|
||||||
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start
|
(listened_at AT TIME ZONE $1::text)::date as day,
|
||||||
),
|
COUNT(*) AS listen_count
|
||||||
filtered_listens AS (
|
FROM listens l
|
||||||
SELECT l.track_id, l.listened_at, l.client, l.user_id
|
JOIN tracks t ON l.track_id = t.id
|
||||||
FROM listens l
|
WHERE l.listened_at >= $2
|
||||||
JOIN tracks t ON l.track_id = t.id
|
AND l.listened_at < $3
|
||||||
WHERE t.id = $4
|
AND t.id = $4
|
||||||
),
|
GROUP BY day
|
||||||
bucketed_listens AS (
|
ORDER BY day
|
||||||
SELECT
|
|
||||||
b.bucket_start,
|
|
||||||
COUNT(l.listened_at) AS listen_count
|
|
||||||
FROM buckets b
|
|
||||||
LEFT JOIN filtered_listens l
|
|
||||||
ON l.listened_at >= b.bucket_start
|
|
||||||
AND l.listened_at < b.bucket_start + $3::interval
|
|
||||||
GROUP BY b.bucket_start
|
|
||||||
ORDER BY b.bucket_start
|
|
||||||
)
|
|
||||||
SELECT bucket_start, listen_count FROM bucketed_listens
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListenActivityForTrackParams struct {
|
type ListenActivityForTrackParams struct {
|
||||||
Column1 time.Time
|
Column1 string
|
||||||
Column2 time.Time
|
ListenedAt time.Time
|
||||||
Column3 pgtype.Interval
|
ListenedAt_2 time.Time
|
||||||
ID int32
|
ID int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListenActivityForTrackRow struct {
|
type ListenActivityForTrackRow struct {
|
||||||
BucketStart time.Time
|
Day pgtype.Date
|
||||||
ListenCount int64
|
ListenCount int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListenActivityForTrack(ctx context.Context, arg ListenActivityForTrackParams) ([]ListenActivityForTrackRow, error) {
|
func (q *Queries) ListenActivityForTrack(ctx context.Context, arg ListenActivityForTrackParams) ([]ListenActivityForTrackRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listenActivityForTrack,
|
rows, err := q.db.Query(ctx, listenActivityForTrack,
|
||||||
arg.Column1,
|
arg.Column1,
|
||||||
arg.Column2,
|
arg.ListenedAt,
|
||||||
arg.Column3,
|
arg.ListenedAt_2,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -895,7 +876,7 @@ func (q *Queries) ListenActivityForTrack(ctx context.Context, arg ListenActivity
|
||||||
var items []ListenActivityForTrackRow
|
var items []ListenActivityForTrackRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListenActivityForTrackRow
|
var i ListenActivityForTrackRow
|
||||||
if err := rows.Scan(&i.BucketStart, &i.ListenCount); err != nil {
|
if err := rows.Scan(&i.Day, &i.ListenCount); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,12 @@ func DateRange(week, month, year int) (time.Time, time.Time, error) {
|
||||||
return start, end, nil
|
return start, end, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns a time.Time that represents the first moment of the day of t.
|
||||||
|
func BeginningOfDay(t time.Time) time.Time {
|
||||||
|
year, month, day := t.Date()
|
||||||
|
return time.Date(year, month, day, 0, 0, 0, 0, t.Location())
|
||||||
|
}
|
||||||
|
|
||||||
// CopyFile copies a file from src to dst. If src and dst files exist, and are
|
// CopyFile copies a file from src to dst. If src and dst files exist, and are
|
||||||
// the same, then return success. Otherise, attempt to create a hard link
|
// the same, then return success. Otherise, attempt to create a hard link
|
||||||
// between the two files. If that fail, copy the file contents from src to dst.
|
// between the two files. If that fail, copy the file contents from src to dst.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue