Compare commits

...
6 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5.5 823d1eb841 fix(web): suggestions stay open when you return to the search quickly
ci / ts / ts (packages + web) (push) Successful in 1m1s
release / android (APK в релиз) (push) Successful in 4m10s
release / release (версия и заметки) (push) Successful in 6s
Leaving the field scheduled a close 120 ms later; coming back and typing
within that window let the old timer hide the fresh suggestions. Focus now
cancels the pending close. This also made the search box test flaky.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:13:08 +03:00
mrleo1nidandClaude Opus 5.5 860072e542 feat(web): choose where the search sits, and controls no longer select text
ci / api / api (push) Successful in 36s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 48s
ci / ts / ts (packages + web) (push) Canceled after 12s
ci / db / db (push) Successful in 2m43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 59s
- settings get a search position (left, center by default, right); the top bar
  is three zones and the suggestions open towards the free space
- buttons, chips, tabs, menu items and the home screen controls are not
  text-selectable, so clicks and double clicks no longer highlight labels

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:11:06 +03:00
mrleo1nidandClaude Opus 5.5 d2f17d8d62 ci(db): fail early with a clear message when the runner disk is full
ci / db / db (push) Successful in 45s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 3m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:03:32 +03:00
mrleo1nidandClaude Opus 5.5 4763339335 ci(db): wait for the service containers before the tests
ci / db / db (push) Successful in 53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:59:28 +03:00
mrleo1nidandClaude Opus 5.5 fda6ed48ab feat: Android download page, analysis retry and a collapsible search
ci / api / api (push) Successful in 37s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 46s
ci / db / db (push) Failing after 14s
ci / recommender / recommender (push) Successful in 51s
ci / ts / ts (packages + web) (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
- /android is a public page with the latest APK, the server address to copy
  and three install steps; linked from the sign-in screen and the profile menu
- failed audio analysis can be retried from the library page, which also shows
  the most common error; the analyzer no longer burns attempts on files that
  are missing because the disk is not mounted
- the top bar search folds into a round button and slides open on click, "/"
  or Ctrl+K

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:55:51 +03:00
mrleo1nidandClaude Opus 5.5 2d49cb3f19 fix(web): header dropdowns sit above the page again
ci / ts / ts (packages + web) (push) Successful in 50s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m45s
Every shell child got the same z-index, so the page painted over the search
suggestions and the profile menu and swallowed their clicks: suggestions could
not be picked, and the language switch and settings item did nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:47:18 +03:00
35 changed files with 902 additions and 301 deletions

No files matched your search

+17
View File
@@ -54,6 +54,23 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum } with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
# Контейнеры сервисов стартуют параллельно с job: без ожидания тесты иногда не находят даже DNS-имя postgres.
- name: Место на раннере и готовность Postgres, Redis, Meilisearch
run: |
# Диск раннера общий с контейнерами сервисов: при нехватке места Postgres падает посреди тестов.
free=$(df -Pm / | awk 'NR==2 {print $4}')
echo "свободно на раннере: ${free} МБ"
[ "$free" -ge 2048 ] || { echo "на раннере меньше 2 ГБ — почистите: docker system prune -af" >&2; exit 1; }
for svc in postgres:5432 redis:6379 meilisearch:7700; do
host=${svc%:*}; port=${svc#*:}
for i in $(seq 1 60); do
if getent hosts "$host" >/dev/null && timeout 2 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
echo "$svc готов"; break
fi
[ "$i" = 60 ] && { echo "$svc не поднялся за 2 минуты" >&2; exit 1; }
sleep 2
done
done
- name: Go — миграции и запросы store - name: Go — миграции и запросы store
working-directory: services/api working-directory: services/api
run: go test -tags integration -count=1 -v ./internal/dbtest/ run: go test -tags integration -count=1 -v ./internal/dbtest/
+29 -1
View File
@@ -1006,6 +1006,24 @@ paths:
$ref: "#/components/schemas/SearchResults" $ref: "#/components/schemas/SearchResults"
default: default:
$ref: "#/components/responses/Problem" $ref: "#/components/responses/Problem"
/api/v1/admin/library/analysis/retry:
post:
operationId: retryAnalysis
tags: [library]
summary: Вернуть в очередь треки, которые не удалось проанализировать (admin)
responses:
"200":
description: Сколько задач снова в очереди
content:
application/json:
schema:
type: object
required: [requeued]
properties:
requeued:
type: integer
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/search/reindex: /api/v1/admin/search/reindex:
post: post:
operationId: reindexSearch operationId: reindexSearch
@@ -1379,6 +1397,7 @@ paths:
get: get:
operationId: getAndroidLatest operationId: getAndroidLatest
tags: [app] tags: [app]
security: [] # публично: страница /android раздаёт приложение без входа
summary: Последняя версия Android-приложения (из релизов Gitea через сервер) summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
description: | description: |
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
@@ -1396,6 +1415,7 @@ paths:
get: get:
operationId: downloadAndroidApk operationId: downloadAndroidApk
tags: [app] tags: [app]
security: []
summary: APK версии (кэшируется на сервере, поддерживает Range для докачки) summary: APK версии (кэшируется на сервере, поддерживает Range для докачки)
parameters: parameters:
- name: version - name: version
@@ -1994,6 +2014,10 @@ components:
web_player: web_player:
type: string type: string
enum: [bar, island] enum: [bar, island]
search_position:
type: string
enum: [center, left, right]
description: Где в топбаре веба поиск (по умолчанию center)
mobile_player: mobile_player:
type: string type: string
enum: [cover, wave] enum: [cover, wave]
@@ -3081,7 +3105,7 @@ components:
description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители) description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители)
AnalysisStatus: AnalysisStatus:
type: object type: object
required: [queued, running, done, failed, analyzer_ver] required: [queued, running, done, failed, analyzer_ver, top_error]
properties: properties:
queued: queued:
type: integer type: integer
@@ -3094,6 +3118,10 @@ components:
analyzer_ver: analyzer_ver:
type: string type: string
nullable: true nullable: true
top_error:
type: string
nullable: true
description: Самая частая ошибка среди неудавшихся — подсказка, что чинить
WaveMetrics: WaveMetrics:
type: object type: object
required: [served, listen_through_rate, early_skip_rate, like_rate, sessions, by_model] required: [served, listen_through_rate, early_skip_rate, like_rate, sessions, by_model]
+5
View File
@@ -21,6 +21,7 @@ import { AlbumPage } from '../pages/AlbumPage';
import { ArtistPage } from '../pages/ArtistPage'; import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage'; import { CollectionPage } from '../pages/CollectionPage';
import { HomePage } from '../pages/HomePage'; import { HomePage } from '../pages/HomePage';
import { AppDownloadPage } from '../pages/AppDownloadPage';
import { LoginPage } from '../pages/LoginPage'; import { LoginPage } from '../pages/LoginPage';
import { SearchPage, type SearchType } from '../pages/SearchPage'; import { SearchPage, type SearchType } from '../pages/SearchPage';
import { SettingsPage } from '../pages/SettingsPage'; import { SettingsPage } from '../pages/SettingsPage';
@@ -59,6 +60,9 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
errorComponent: RouteError, errorComponent: RouteError,
}); });
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
const loginRoute = createRoute({ const loginRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/login', path: '/login',
@@ -233,6 +237,7 @@ const adminSettingsRoute = createRoute({
}); });
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
androidAppRoute,
loginRoute, loginRoute,
setupAdminRoute, setupAdminRoute,
setupPasswordRoute, setupPasswordRoute,
+70
View File
@@ -0,0 +1,70 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { formatBytes } from '../shared/lib/format';
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
import { Icon } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout';
import s from './auth.module.css';
/**
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
*/
export function AppDownloadPage() {
const { t, i18n } = useTranslation(['auth', 'common']);
const session = useSession();
const [copied, setCopied] = useState(false);
const latest = useQuery({
queryKey: ['android-latest'],
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
staleTime: 10 * 60_000,
});
const server = location.origin;
const copy = () =>
void navigator.clipboard?.writeText(server).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
const r = latest.data;
return (
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
<span className={s.badge}>Android</span>
<h1>{t('app.title')}</h1>
<p className={s.lead}>{t('app.subtitle')}</p>
{latest.isPending ? null : r ? (
<>
<a className={s.download} href={r.download_url} download>
<Icon src={downloadIcon} size={22} />
<span>
{t('app.download')}
<span className={s.downloadMeta}>
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
</span>
</span>
</a>
<ol className={s.steps}>
<li>{t('app.step_install')}</li>
<li>
{t('app.step_server')}
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
<code>{server}</code>
<Icon src={copied ? checkIcon : copyIcon} size={16} />
</button>
</li>
<li>{t('app.step_updates')}</li>
</ol>
<details className={s.details}>
<summary>{t('app.checksum')}</summary>
<code>{r.sha256}</code>
</details>
</>
) : (
<div className={s.hint}>{t('app.unavailable')}</div>
)}
</AuthLayout>
);
}
+9 -1
View File
@@ -37,7 +37,15 @@ export function LoginPage() {
}; };
return ( return (
<AuthLayout footer={info.data ? `pipu ${info.data.version}` : undefined}> <AuthLayout
footer={
info.data ? (
<>
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
</>
) : undefined
}
>
<h1>{t('login.title')}</h1> <h1>{t('login.title')}</h1>
<p className={s.lead}>{t('login.subtitle')}</p> <p className={s.lead}>{t('login.subtitle')}</p>
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>} {info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
+13
View File
@@ -126,6 +126,19 @@ function AppearanceSection() {
]} ]}
/> />
</div> </div>
<div className={s.settingRow}>
<span>{t('settings.search_position')}</span>
<Segmented<'left' | 'center' | 'right'>
label={t('settings.search_position')}
value={user.ui_prefs.search_position ?? 'center'}
onChange={(v) => void setPrefs({ search_position: v })}
options={[
{ value: 'left', label: t('settings.search_left') },
{ value: 'center', label: t('settings.search_center') },
{ value: 'right', label: t('settings.search_right') },
]}
/>
</div>
<div className={s.settingRow}> <div className={s.settingRow}>
<span>{t('settings.ambient')}</span> <span>{t('settings.ambient')}</span>
<Segmented<'on' | 'off'> <Segmented<'on' | 'off'>
+19 -2
View File
@@ -1,5 +1,5 @@
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useId } from 'react'; import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -32,6 +32,11 @@ export function LibraryPage() {
const reindex = useMutation({ const reindex = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')), mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
}); });
const queryClient = useQueryClient();
const retry = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }),
});
const d = status.data; const d = status.data;
const tiles = d const tiles = d
? ([ ? ([
@@ -77,7 +82,19 @@ export function LibraryPage() {
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed, total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
})} })}
/> />
{d.analysis.failed > 0 && <span className={l.scanError}>{t('library.analysis_failed', { count: d.analysis.failed })}</span>} {d.analysis.failed > 0 && (
<div className={l.retryRow}>
<span className={l.scanError}>
{t('library.analysis_failed', { count: d.analysis.failed })}
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
</span>
{admin && (
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
{t('library.analysis_retry')}
</Button>
)}
</div>
)}
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>} {d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
</section> </section>
)} )}
@@ -118,6 +118,20 @@
color: var(--danger); color: var(--danger);
} }
.retryRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.errorDetail {
display: block;
margin-top: 4px;
color: var(--text3);
word-break: break-word;
}
.bar { .bar {
height: 6px; height: 6px;
border-radius: 3px; border-radius: 3px;
+77
View File
@@ -161,6 +161,83 @@
color: var(--text3); color: var(--text3);
} }
.footer a {
color: var(--text2);
}
/* Страница Android-приложения (/android). */
.download {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
border-radius: var(--radius-lg);
background: var(--accent-fill);
color: #fff;
font-weight: 700;
text-decoration: none;
box-shadow: var(--glow);
transition:
background 0.2s,
transform 0.15s;
}
.download:hover {
background: var(--accent-fill2);
}
.download:active {
transform: scale(0.98);
}
.downloadMeta {
display: block;
margin-top: 2px;
font-size: 12px;
font-weight: 500;
opacity: 0.85;
}
.steps {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--text2);
font-size: 14px;
line-height: 1.5;
}
.server {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: 6px;
padding: 2px 10px;
border-radius: 8px;
border: 1px solid var(--line2);
background: var(--surface2);
color: var(--text);
cursor: pointer;
}
.details {
font-size: 12px;
color: var(--text3);
}
.details summary {
cursor: pointer;
}
.details code {
display: block;
margin-top: 6px;
word-break: break-all;
font-family: var(--font-mono);
}
.link { .link {
align-self: center; align-self: center;
} }
+1
View File
@@ -7,6 +7,7 @@
gap: 28px; gap: 28px;
padding: 24px 0; padding: 24px 0;
text-align: center; text-align: center;
user-select: none; /* главная — только элементы управления волной */
} }
.orbLabel { .orbLabel {
+2
View File
@@ -23,6 +23,8 @@ export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.sv
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg'; export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg'; export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg'; export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
export { default as downloadIcon } from '@material-symbols/svg-400/rounded/download.svg';
export { default as androidIcon } from '@material-symbols/svg-400/rounded/phone_android.svg';
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg'; export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg';
export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg'; export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg'; export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
+13
View File
@@ -41,6 +41,19 @@ button {
cursor: pointer; cursor: pointer;
} }
/* Элементы управления не выделяются при кликах и двойных кликах — текст остаётся выделяемым на страницах. */
button,
[role='button'],
[role='tab'],
[role='option'],
[role='menuitem'],
[role='listitem'],
nav a,
label {
user-select: none;
-webkit-user-select: none;
}
:focus-visible { :focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
+19 -3
View File
@@ -27,12 +27,15 @@
padding-bottom: 132px; padding-bottom: 132px;
} }
/* Над контентом: выпадающие подсказки поиска и меню профиля перекрывают страницу, а не уходят под неё. */
.top { .top {
grid-area: top; grid-area: top;
display: flex; z-index: 30;
/* Три зоны: логотип, середина, профиль; поиск — в одной из них (настройка «Расположение поиска»). */
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
gap: 24px; gap: 16px;
justify-content: space-between;
padding: 0 20px; padding: 0 20px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: var(--bar-a); background: var(--bar-a);
@@ -51,9 +54,22 @@
} }
} }
.topLeft {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.topCenter {
display: flex;
justify-content: center;
}
.topRight { .topRight {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 12px; gap: 12px;
} }
+12 -4
View File
@@ -22,6 +22,7 @@ import {
dislikeFillIcon, dislikeFillIcon,
dislikeIcon, dislikeIcon,
settingsIcon, settingsIcon,
androidIcon,
storageFillIcon, storageFillIcon,
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons'; storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui'; import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
@@ -92,6 +93,8 @@ function Shell() {
const name = user.display_name ?? user.username; const name = user.display_name ?? user.username;
const staff = user.role !== 'user'; const staff = user.role !== 'user';
const island = user.ui_prefs.web_player === 'island'; const island = user.ui_prefs.web_player === 'island';
const searchAt = user.ui_prefs.search_position ?? 'center';
const search = <SearchBox align={searchAt} />;
const counts = useCollectionCounts(); const counts = useCollectionCounts();
const pending = usePendingUploads(user.role === 'admin'); const pending = usePendingUploads(user.role === 'admin');
@@ -99,11 +102,15 @@ function Shell() {
<div className={cx(s.shell, island && s.island)}> <div className={cx(s.shell, island && s.island)}>
{user.ui_prefs.ambient !== false && <ShellAmbient />} {user.ui_prefs.ambient !== false && <ShellAmbient />}
<header className={s.top}> <header className={s.top}>
<Link to="/" className={s.logoLink}> <div className={s.topLeft}>
<Logo /> <Link to="/" className={s.logoLink}>
</Link> <Logo />
<SearchBox /> </Link>
{searchAt === 'left' && search}
</div>
<div className={s.topCenter}>{searchAt === 'center' && search}</div>
<div className={s.topRight}> <div className={s.topRight}>
{searchAt === 'right' && search}
<Menu <Menu
label={t('profile.menu')} label={t('profile.menu')}
triggerClassName={s.profileButton} triggerClassName={s.profileButton}
@@ -122,6 +129,7 @@ function Shell() {
} }
items={[ items={[
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) }, { label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() }, { label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
]} ]}
/> />
+47 -6
View File
@@ -1,12 +1,17 @@
.box { .box {
position: relative; position: relative;
flex: 1; flex: none;
max-width: 520px; width: 40px;
transition: width 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.expanded {
width: min(520px, 46vw);
} }
.icon { .icon {
position: absolute; position: absolute;
left: 14px; left: 11px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
color: var(--text3); color: var(--text3);
@@ -16,13 +21,36 @@
.input { .input {
width: 100%; width: 100%;
height: 40px; height: 40px;
padding: 0 40px 0 42px; padding: 0 40px 0 40px;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
border: 1px solid var(--line); border: 1px solid var(--line);
background: var(--surface2); background: var(--surface2);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
outline: none; outline: none;
transition:
border-color 0.2s,
box-shadow 0.2s,
background 0.2s;
}
/* Свёрнутое поле — круглая кнопка: без подсказки и курсора ввода. */
.box:not(.expanded) .input {
padding: 0;
cursor: pointer;
color: transparent;
}
.box:not(.expanded) .input::placeholder {
color: transparent;
}
.box:not(.expanded) .input:hover {
background: var(--surface3);
}
.box:not(.expanded) .kbd {
display: none;
} }
.input:focus { .input:focus {
@@ -56,8 +84,7 @@
.popover { .popover {
position: absolute; position: absolute;
left: 0; width: min(520px, calc(100vw - 32px));
right: 0;
top: calc(100% + 8px); top: calc(100% + 8px);
z-index: 45; z-index: 45;
max-height: min(70vh, 560px); max-height: min(70vh, 560px);
@@ -70,6 +97,20 @@
animation: pipuIn 0.15s ease-out; animation: pipuIn 0.15s ease-out;
} }
/* Подсказки раскрываются в сторону свободного места: слева — вправо, справа — влево, по центру — от середины. */
.left .popover {
left: 0;
}
.right .popover {
right: 0;
}
.center .popover {
left: 50%;
transform: translateX(-50%);
}
.corrected { .corrected {
margin: 4px 10px 8px; margin: 4px 10px 8px;
font-size: 13px; font-size: 13px;
+20 -5
View File
@@ -23,8 +23,11 @@ type Item = {
go: () => void; go: () => void;
}; };
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */ /**
export function SearchBox() { * Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент.
* Без фокуса и текста свёрнут в кнопку с лупой; поле раскрывается влево с анимацией.
*/
export function SearchBox({ align = 'center' }: { align?: 'left' | 'center' | 'right' }) {
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const session = useSession();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -34,6 +37,10 @@ export function SearchBox() {
const [debounced, setDebounced] = useState(''); const [debounced, setDebounced] = useState('');
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1); const [active, setActive] = useState(-1);
const [focused, setFocused] = useState(false);
const blurTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
// Свёрнут в круглую кнопку, пока не нужен: раскрывается по клику, «/» или Ctrl+K.
const expanded = focused || q !== '';
useEffect(() => { useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS); const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
@@ -141,7 +148,7 @@ export function SearchBox() {
const optionId = (i: number) => `${listId}-${i}`; const optionId = (i: number) => `${listId}-${i}`;
return ( return (
<div className={s.box}> <div className={cx(s.box, expanded && s.expanded, s[align])}>
<Icon src={searchIcon} size={18} className={s.icon} /> <Icon src={searchIcon} size={18} className={s.icon} />
<input <input
ref={inputRef} ref={inputRef}
@@ -160,8 +167,16 @@ export function SearchBox() {
setOpen(true); setOpen(true);
setActive(-1); setActive(-1);
}} }}
onFocus={() => setOpen(true)} onFocus={() => {
onBlur={() => setTimeout(close, 120)} // Вернулись в поле раньше, чем сработало закрытие по blur, — подсказки не прячем.
clearTimeout(blurTimer.current);
setFocused(true);
setOpen(true);
}}
onBlur={() => {
setFocused(false);
blurTimer.current = setTimeout(close, 120);
}}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
<kbd className={s.kbd} aria-hidden> <kbd className={s.kbd} aria-hidden>
+5
View File
@@ -49,6 +49,9 @@
индекс обновляется сам и пересобирается при старте api). индекс обновляется сам и пересобирается при старте api).
- **Аудиоанализ** (темп, энергия, настроение — для «Моей волны») идёт в фоне: на 10 тыс. треков — несколько часов - **Аудиоанализ** (темп, энергия, настроение — для «Моей волны») идёт в фоне: на 10 тыс. треков — несколько часов
при 4 ядрах (`analyzer` в `compose.yaml`, `--workers`). Волна работает и до его окончания, но точнее — после. при 4 ядрах (`analyzer` в `compose.yaml`, `--workers`). Волна работает и до его окончания, но точнее — после.
Не удалось проанализировать часть треков (например, пока диск не был смонтирован) — «Повторить» под счётчиком
ошибок возвращает их в очередь; рядом показана самая частая ошибка. Пропавшие файлы анализатор не считает
попыткой и ждёт, пока диск вернётся.
## 4. Очередь на добавление ## 4. Очередь на добавление
@@ -91,6 +94,8 @@
прочитайте заметки к релизу на странице релизов Gitea. прочитайте заметки к релизу на странице релизов Gitea.
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK. Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
## 7. Бэкапы и восстановление ## 7. Бэкапы и восстановление
+2
View File
@@ -82,6 +82,8 @@
## Самообновление ## Самообновление
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
Приложение распространяется только через Gitea Releases (не Google Play). Приложение распространяется только через Gitea Releases (не Google Play).
**Поток:** **Поток:**
@@ -880,6 +880,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/admin/library/analysis/retry": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Вернуть в очередь треки, которые не удалось проанализировать (admin) */
post: operations["retryAnalysis"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/search/reindex": { "/api/v1/admin/search/reindex": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1492,6 +1509,11 @@ export interface components {
wave_variant?: "orb" | "liquid"; wave_variant?: "orb" | "liquid";
/** @enum {string} */ /** @enum {string} */
web_player?: "bar" | "island"; web_player?: "bar" | "island";
/**
* @description Где в топбаре веба поиск (по умолчанию center)
* @enum {string}
*/
search_position?: "center" | "left" | "right";
/** @enum {string} */ /** @enum {string} */
mobile_player?: "cover" | "wave"; mobile_player?: "cover" | "wave";
ambient?: boolean; ambient?: boolean;
@@ -2012,6 +2034,8 @@ export interface components {
done: number; done: number;
failed: number; failed: number;
analyzer_ver: string | null; analyzer_ver: string | null;
/** @description Самая частая ошибка среди неудавшихся — подсказка, что чинить */
top_error: string | null;
}; };
/** @description Метрики волны за 7 дней */ /** @description Метрики волны за 7 дней */
WaveMetrics: { WaveMetrics: {
@@ -3355,6 +3379,29 @@ export interface operations {
default: components["responses"]["Problem"]; default: components["responses"]["Problem"];
}; };
}; };
retryAnalysis: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Сколько задач снова в очереди */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
requeued: number;
};
};
};
default: components["responses"]["Problem"];
};
};
reindexSearch: { reindexSearch: {
parameters: { parameters: {
query?: never; query?: never;
+2 -1
View File
@@ -160,7 +160,8 @@
"wave_early_skip": "Early skips", "wave_early_skip": "Early skips",
"wave_likes": "Likes per 100", "wave_likes": "Likes per 100",
"wave_model": "Model version", "wave_model": "Model version",
"wave_by_model": "Wave model versions compared" "wave_by_model": "Wave model versions compared",
"analysis_retry": "Retry"
}, },
"write_template": { "write_template": {
"title": "Uploads and path template", "title": "Uploads and path template",
+14
View File
@@ -20,5 +20,19 @@
"min_length": "At least {{min}} characters", "min_length": "At least {{min}} characters",
"submit": "Save and continue", "submit": "Save and continue",
"logout": "Sign out" "logout": "Sign out"
},
"app": {
"link": "Android app",
"title": "pipu for Android",
"subtitle": "Your music from this server on your phone: My Wave, your collection and offline listening.",
"download": "Download APK",
"version": "version {{version}}",
"step_install": "Open the downloaded file. If Android asks, allow installs from this source.",
"step_server": "Launch pipu and enter the server address:",
"copy_server": "Copy the server address",
"step_updates": "Sign in with your account. The app offers new versions by itself.",
"checksum": "SHA-256 checksum",
"unavailable": "The app is not available yet: release downloads are not configured on this server (docs/deployment.md).",
"to_login": "Sign in to the web app"
} }
} }
+7 -2
View File
@@ -39,7 +39,8 @@
"profile": { "profile": {
"menu": "Profile menu", "menu": "Profile menu",
"settings": "Settings", "settings": "Settings",
"logout": "Sign out" "logout": "Sign out",
"android_app": "Android app"
}, },
"roles": { "roles": {
"admin": "Administrator", "admin": "Administrator",
@@ -114,7 +115,11 @@
"player_island": "Island", "player_island": "Island",
"ambient": "Ambient background", "ambient": "Ambient background",
"on": "On", "on": "On",
"off": "Off" "off": "Off",
"search_position": "Search position",
"search_left": "Left",
"search_center": "Center",
"search_right": "Right"
}, },
"form": { "form": {
"show_password": "Show password", "show_password": "Show password",
+2 -1
View File
@@ -160,7 +160,8 @@
"wave_early_skip": "Ранние скипы", "wave_early_skip": "Ранние скипы",
"wave_likes": "Лайков на 100", "wave_likes": "Лайков на 100",
"wave_model": "Версия модели", "wave_model": "Версия модели",
"wave_by_model": "Сравнение версий модели волны" "wave_by_model": "Сравнение версий модели волны",
"analysis_retry": "Повторить"
}, },
"write_template": { "write_template": {
"title": "Приём загрузок и шаблон пути", "title": "Приём загрузок и шаблон пути",
+14
View File
@@ -20,5 +20,19 @@
"min_length": "Не короче {{min}} символов", "min_length": "Не короче {{min}} символов",
"submit": "Сохранить и продолжить", "submit": "Сохранить и продолжить",
"logout": "Выйти" "logout": "Выйти"
},
"app": {
"link": "Приложение для Android",
"title": "pipu для Android",
"subtitle": "Ваша музыка с этого сервера на телефоне: «Моя волна», коллекция и офлайн-прослушивание.",
"download": "Скачать APK",
"version": "версия {{version}}",
"step_install": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
"step_server": "Запустите pipu и введите адрес сервера:",
"copy_server": "Скопировать адрес сервера",
"step_updates": "Войдите своим логином. Новые версии приложение предложит установить само.",
"checksum": "Контрольная сумма SHA-256",
"unavailable": "Приложение пока недоступно: на сервере не настроена загрузка релизов (docs/deployment.md).",
"to_login": "Войти в веб-версию"
} }
} }
+7 -2
View File
@@ -39,7 +39,8 @@
"profile": { "profile": {
"menu": "Меню профиля", "menu": "Меню профиля",
"settings": "Настройки", "settings": "Настройки",
"logout": "Выйти" "logout": "Выйти",
"android_app": "Приложение для Android"
}, },
"roles": { "roles": {
"admin": "Администратор", "admin": "Администратор",
@@ -114,7 +115,11 @@
"player_island": "Остров", "player_island": "Остров",
"ambient": "Живой фон", "ambient": "Живой фон",
"on": "Вкл.", "on": "Вкл.",
"off": "Выкл." "off": "Выкл.",
"search_position": "Расположение поиска",
"search_left": "Слева",
"search_center": "По центру",
"search_right": "Справа"
}, },
"form": { "form": {
"show_password": "Показать пароль", "show_password": "Показать пароль",
+353 -257
View File
@@ -137,6 +137,13 @@ const (
UiPrefsMobilePlayerWave UiPrefsMobilePlayer = "wave" UiPrefsMobilePlayerWave UiPrefsMobilePlayer = "wave"
) )
// Defines values for UiPrefsSearchPosition.
const (
Center UiPrefsSearchPosition = "center"
Left UiPrefsSearchPosition = "left"
Right UiPrefsSearchPosition = "right"
)
// Defines values for UiPrefsTheme. // Defines values for UiPrefsTheme.
const ( const (
Dark UiPrefsTheme = "dark" Dark UiPrefsTheme = "dark"
@@ -288,6 +295,9 @@ type AnalysisStatus struct {
Failed int `json:"failed"` Failed int `json:"failed"`
Queued int `json:"queued"` Queued int `json:"queued"`
Running int `json:"running"` Running int `json:"running"`
// TopError Самая частая ошибка среди неудавшихся — подсказка, что чинить
TopError *string `json:"top_error"`
} }
// AndroidRelease defines model for AndroidRelease. // AndroidRelease defines model for AndroidRelease.
@@ -907,14 +917,20 @@ type UiPrefs struct {
CrossfadeS *int `json:"crossfade_s,omitempty"` CrossfadeS *int `json:"crossfade_s,omitempty"`
MobilePlayer *UiPrefsMobilePlayer `json:"mobile_player,omitempty"` MobilePlayer *UiPrefsMobilePlayer `json:"mobile_player,omitempty"`
Normalize *bool `json:"normalize,omitempty"` Normalize *bool `json:"normalize,omitempty"`
Theme *UiPrefsTheme `json:"theme,omitempty"`
WaveVariant *UiPrefsWaveVariant `json:"wave_variant,omitempty"` // SearchPosition Где в топбаре веба поиск (по умолчанию center)
WebPlayer *UiPrefsWebPlayer `json:"web_player,omitempty"` SearchPosition *UiPrefsSearchPosition `json:"search_position,omitempty"`
Theme *UiPrefsTheme `json:"theme,omitempty"`
WaveVariant *UiPrefsWaveVariant `json:"wave_variant,omitempty"`
WebPlayer *UiPrefsWebPlayer `json:"web_player,omitempty"`
} }
// UiPrefsMobilePlayer defines model for UiPrefs.MobilePlayer. // UiPrefsMobilePlayer defines model for UiPrefs.MobilePlayer.
type UiPrefsMobilePlayer string type UiPrefsMobilePlayer string
// UiPrefsSearchPosition Где в топбаре веба поиск (по умолчанию center)
type UiPrefsSearchPosition string
// UiPrefsTheme defines model for UiPrefs.Theme. // UiPrefsTheme defines model for UiPrefs.Theme.
type UiPrefsTheme string type UiPrefsTheme string
@@ -1464,6 +1480,9 @@ type ServerInterface interface {
// Журнал действий (admin), новые сверху // Журнал действий (admin), новые сверху
// (GET /api/v1/admin/audit) // (GET /api/v1/admin/audit)
ListAudit(w http.ResponseWriter, r *http.Request, params ListAuditParams) ListAudit(w http.ResponseWriter, r *http.Request, params ListAuditParams)
// Вернуть в очередь треки, которые не удалось проанализировать (admin)
// (POST /api/v1/admin/library/analysis/retry)
RetryAnalysis(w http.ResponseWriter, r *http.Request)
// Файлы, которые не удалось прочитать (admin) // Файлы, которые не удалось прочитать (admin)
// (GET /api/v1/admin/library/errors) // (GET /api/v1/admin/library/errors)
ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams) ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams)
@@ -1695,6 +1714,12 @@ func (_ Unimplemented) ListAudit(w http.ResponseWriter, r *http.Request, params
w.WriteHeader(http.StatusNotImplemented) w.WriteHeader(http.StatusNotImplemented)
} }
// Вернуть в очередь треки, которые не удалось проанализировать (admin)
// (POST /api/v1/admin/library/analysis/retry)
func (_ Unimplemented) RetryAnalysis(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// Файлы, которые не удалось прочитать (admin) // Файлы, которые не удалось прочитать (admin)
// (GET /api/v1/admin/library/errors) // (GET /api/v1/admin/library/errors)
func (_ Unimplemented) ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams) { func (_ Unimplemented) ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams) {
@@ -2183,6 +2208,26 @@ func (siw *ServerInterfaceWrapper) ListAudit(w http.ResponseWriter, r *http.Requ
handler.ServeHTTP(w, r) handler.ServeHTTP(w, r)
} }
// RetryAnalysis operation middleware
func (siw *ServerInterfaceWrapper) RetryAnalysis(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.RetryAnalysis(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// ListLibraryErrors operation middleware // ListLibraryErrors operation middleware
func (siw *ServerInterfaceWrapper) ListLibraryErrors(w http.ResponseWriter, r *http.Request) { func (siw *ServerInterfaceWrapper) ListLibraryErrors(w http.ResponseWriter, r *http.Request) {
@@ -3057,12 +3102,6 @@ func (siw *ServerInterfaceWrapper) DownloadAndroidApk(w http.ResponseWriter, r *
return return
} }
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.DownloadAndroidApk(w, r, version) siw.Handler.DownloadAndroidApk(w, r, version)
})) }))
@@ -3077,12 +3116,6 @@ func (siw *ServerInterfaceWrapper) DownloadAndroidApk(w http.ResponseWriter, r *
// GetAndroidLatest operation middleware // GetAndroidLatest operation middleware
func (siw *ServerInterfaceWrapper) GetAndroidLatest(w http.ResponseWriter, r *http.Request) { func (siw *ServerInterfaceWrapper) GetAndroidLatest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetAndroidLatest(w, r) siw.Handler.GetAndroidLatest(w, r)
})) }))
@@ -4455,6 +4488,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/admin/audit", wrapper.ListAudit) r.Get(options.BaseURL+"/api/v1/admin/audit", wrapper.ListAudit)
}) })
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/api/v1/admin/library/analysis/retry", wrapper.RetryAnalysis)
})
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/admin/library/errors", wrapper.ListLibraryErrors) r.Get(options.BaseURL+"/api/v1/admin/library/errors", wrapper.ListLibraryErrors)
}) })
@@ -4709,6 +4745,36 @@ func (response ListAuditdefaultApplicationProblemPlusJSONResponse) VisitListAudi
return json.NewEncoder(w).Encode(response.Body) return json.NewEncoder(w).Encode(response.Body)
} }
type RetryAnalysisRequestObject struct {
}
type RetryAnalysisResponseObject interface {
VisitRetryAnalysisResponse(w http.ResponseWriter) error
}
type RetryAnalysis200JSONResponse struct {
Requeued int `json:"requeued"`
}
func (response RetryAnalysis200JSONResponse) VisitRetryAnalysisResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type RetryAnalysisdefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response RetryAnalysisdefaultApplicationProblemPlusJSONResponse) VisitRetryAnalysisResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type ListLibraryErrorsRequestObject struct { type ListLibraryErrorsRequestObject struct {
Params ListLibraryErrorsParams Params ListLibraryErrorsParams
} }
@@ -6832,6 +6898,9 @@ type StrictServerInterface interface {
// Журнал действий (admin), новые сверху // Журнал действий (admin), новые сверху
// (GET /api/v1/admin/audit) // (GET /api/v1/admin/audit)
ListAudit(ctx context.Context, request ListAuditRequestObject) (ListAuditResponseObject, error) ListAudit(ctx context.Context, request ListAuditRequestObject) (ListAuditResponseObject, error)
// Вернуть в очередь треки, которые не удалось проанализировать (admin)
// (POST /api/v1/admin/library/analysis/retry)
RetryAnalysis(ctx context.Context, request RetryAnalysisRequestObject) (RetryAnalysisResponseObject, error)
// Файлы, которые не удалось прочитать (admin) // Файлы, которые не удалось прочитать (admin)
// (GET /api/v1/admin/library/errors) // (GET /api/v1/admin/library/errors)
ListLibraryErrors(ctx context.Context, request ListLibraryErrorsRequestObject) (ListLibraryErrorsResponseObject, error) ListLibraryErrors(ctx context.Context, request ListLibraryErrorsRequestObject) (ListLibraryErrorsResponseObject, error)
@@ -7108,6 +7177,30 @@ func (sh *strictHandler) ListAudit(w http.ResponseWriter, r *http.Request, param
} }
} }
// RetryAnalysis operation middleware
func (sh *strictHandler) RetryAnalysis(w http.ResponseWriter, r *http.Request) {
var request RetryAnalysisRequestObject
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.RetryAnalysis(ctx, request.(RetryAnalysisRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "RetryAnalysis")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(RetryAnalysisResponseObject); ok {
if err := validResponse.VisitRetryAnalysisResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// ListLibraryErrors operation middleware // ListLibraryErrors operation middleware
func (sh *strictHandler) ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams) { func (sh *strictHandler) ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams) {
var request ListLibraryErrorsRequestObject var request ListLibraryErrorsRequestObject
@@ -9130,248 +9223,251 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file
+1
View File
@@ -565,5 +565,6 @@ func TestPublicRoutes(t *testing.T) {
e := newEnv(t) e := newEnv(t)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public")
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
} }
+9 -7
View File
@@ -241,13 +241,14 @@ func MeDTO(u User) gen.Me {
func uiPrefsDTO(p UIPrefs) gen.UiPrefs { func uiPrefsDTO(p UIPrefs) gen.UiPrefs {
return gen.UiPrefs{ return gen.UiPrefs{
Theme: (*gen.UiPrefsTheme)(p.Theme), Theme: (*gen.UiPrefsTheme)(p.Theme),
WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant), WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant),
WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer), WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer),
MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer), SearchPosition: (*gen.UiPrefsSearchPosition)(p.SearchPos),
Ambient: p.Ambient, MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer),
CrossfadeS: p.CrossfadeS, Ambient: p.Ambient,
Normalize: p.Normalize, CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
} }
} }
@@ -256,6 +257,7 @@ func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
Theme: (*string)(p.Theme), Theme: (*string)(p.Theme),
WaveVariant: (*string)(p.WaveVariant), WaveVariant: (*string)(p.WaveVariant),
WebPlayer: (*string)(p.WebPlayer), WebPlayer: (*string)(p.WebPlayer),
SearchPos: (*string)(p.SearchPosition),
MobilePlayer: (*string)(p.MobilePlayer), MobilePlayer: (*string)(p.MobilePlayer),
Ambient: p.Ambient, Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS, CrossfadeS: p.CrossfadeS,
+4 -1
View File
@@ -55,6 +55,8 @@ var publicRoutes = map[string]bool{
"GET /api/v1/server-info": true, "GET /api/v1/server-info": true,
"POST /api/v1/auth/login": true, "POST /api/v1/auth/login": true,
"POST /api/v1/auth/refresh": true, "POST /api/v1/auth/refresh": true,
// Android-приложение раздаётся по ссылке /android без входа: в APK нет ничего, кроме клиента.
"GET /api/v1/app/android/latest": true,
} }
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль. // Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
@@ -90,7 +92,8 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
// Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись. // Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись.
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя. // Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) && cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/")) (strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/"))
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover { if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
return return
+1
View File
@@ -37,6 +37,7 @@ type UIPrefs struct {
Theme *string `json:"theme,omitempty"` Theme *string `json:"theme,omitempty"`
WaveVariant *string `json:"wave_variant,omitempty"` WaveVariant *string `json:"wave_variant,omitempty"`
WebPlayer *string `json:"web_player,omitempty"` WebPlayer *string `json:"web_player,omitempty"`
SearchPos *string `json:"search_position,omitempty"`
MobilePlayer *string `json:"mobile_player,omitempty"` MobilePlayer *string `json:"mobile_player,omitempty"`
Ambient *bool `json:"ambient,omitempty"` Ambient *bool `json:"ambient,omitempty"`
CrossfadeS *int `json:"crossfade_s,omitempty"` CrossfadeS *int `json:"crossfade_s,omitempty"`
+9 -4
View File
@@ -86,10 +86,11 @@ var commonPasswords = func() map[string]struct{} {
}() }()
var uiPrefEnums = map[string][]string{ var uiPrefEnums = map[string][]string{
"theme": {"dark", "light", "system"}, "theme": {"dark", "light", "system"},
"wave_variant": {"orb", "liquid"}, "wave_variant": {"orb", "liquid"},
"web_player": {"bar", "island"}, "web_player": {"bar", "island"},
"mobile_player": {"cover", "wave"}, "search_position": {"center", "left", "right"},
"mobile_player": {"cover", "wave"},
} }
// ValidateUIPrefs проверяет значения перечислений и диапазон кроссфейда. // ValidateUIPrefs проверяет значения перечислений и диапазон кроссфейда.
@@ -107,6 +108,7 @@ func ValidateUIPrefs(p UIPrefs) error {
} }
for name, v := range map[string]*string{ for name, v := range map[string]*string{
"theme": p.Theme, "wave_variant": p.WaveVariant, "web_player": p.WebPlayer, "mobile_player": p.MobilePlayer, "theme": p.Theme, "wave_variant": p.WaveVariant, "web_player": p.WebPlayer, "mobile_player": p.MobilePlayer,
"search_position": p.SearchPos,
} { } {
if err := check(name, v); err != nil { if err := check(name, v); err != nil {
return err return err
@@ -130,6 +132,9 @@ func MergeUIPrefs(base, patch UIPrefs) UIPrefs {
if patch.WebPlayer != nil { if patch.WebPlayer != nil {
base.WebPlayer = patch.WebPlayer base.WebPlayer = patch.WebPlayer
} }
if patch.SearchPos != nil {
base.SearchPos = patch.SearchPos
}
if patch.MobilePlayer != nil { if patch.MobilePlayer != nil {
base.MobilePlayer = patch.MobilePlayer base.MobilePlayer = patch.MobilePlayer
} }
+10 -1
View File
@@ -292,6 +292,15 @@ func (h *Handler) ScanSource(ctx context.Context, req gen.ScanSourceRequestObjec
return gen.ScanSource202JSONResponse(progressDTO(p)), nil return gen.ScanSource202JSONResponse(progressDTO(p)), nil
} }
// RetryAnalysis реализует gen.StrictServerInterface.
func (h *Handler) RetryAnalysis(ctx context.Context, _ gen.RetryAnalysisRequestObject) (gen.RetryAnalysisResponseObject, error) {
n, err := h.svc.RetryAnalysis(ctx)
if err != nil {
return nil, err
}
return gen.RetryAnalysis200JSONResponse{Requeued: n}, nil
}
// GetLibraryStatus реализует gen.StrictServerInterface. // GetLibraryStatus реализует gen.StrictServerInterface.
func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRequestObject) (gen.GetLibraryStatusResponseObject, error) { func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRequestObject) (gen.GetLibraryStatusResponseObject, error) {
st, err := h.svc.Status(ctx) st, err := h.svc.Status(ctx)
@@ -304,7 +313,7 @@ func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRe
MissingFiles: int(c.Missing), FileErrors: int(c.Errors), Sources: []gen.SourceStatusItem{}, MissingFiles: int(c.Missing), FileErrors: int(c.Errors), Sources: []gen.SourceStatusItem{},
Analysis: gen.AnalysisStatus{ Analysis: gen.AnalysisStatus{
Queued: st.Analysis.Queued, Running: st.Analysis.Running, Done: st.Analysis.Done, Queued: st.Analysis.Queued, Running: st.Analysis.Running, Done: st.Analysis.Done,
Failed: st.Analysis.Failed, AnalyzerVer: st.Analysis.AnalyzerVer, Failed: st.Analysis.Failed, AnalyzerVer: st.Analysis.AnalyzerVer, TopError: st.Analysis.TopError,
}, },
Wave: gen.WaveMetrics{ Wave: gen.WaveMetrics{
Served: st.Wave.Served, Sessions: st.Wave.Sessions, ListenThroughRate: st.Wave.ListenThrough, Served: st.Wave.Served, Sessions: st.Wave.Sessions, ListenThroughRate: st.Wave.ListenThrough,
+9
View File
@@ -587,6 +587,15 @@ func (s *Service) previewOne(ctx context.Context, root, rel string, patterns []m
} }
// StartScan запускает скан источника в фоне. // StartScan запускает скан источника в фоне.
// RetryAnalysis возвращает в очередь треки, которые анализатор не смог обработать (например, пока диск
// не был смонтирован).
func (s *Service) RetryAnalysis(ctx context.Context) (int, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return 0, err
}
return s.store.RetryFailedAnalysis(ctx)
}
func (s *Service) StartScan(ctx context.Context, id uuid.UUID, full bool) (Progress, error) { func (s *Service) StartScan(ctx context.Context, id uuid.UUID, full bool) (Progress, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil { if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return Progress{}, err return Progress{}, err
+16 -3
View File
@@ -605,7 +605,7 @@ func (st *Store) AlbumCoverPath(ctx context.Context, albumID uuid.UUID) (*string
// AnalysisCounts — состояние очереди аудиоанализа. // AnalysisCounts — состояние очереди аудиоанализа.
type AnalysisCounts struct { type AnalysisCounts struct {
Queued, Running, Done, Failed int Queued, Running, Done, Failed int
AnalyzerVer *string AnalyzerVer, TopError *string
} }
// AnalysisCounts считает задачи анализа по статусам и текущую версию анализатора. // AnalysisCounts считает задачи анализа по статусам и текущую версию анализатора.
@@ -614,14 +614,27 @@ func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
err := st.pool.QueryRow(ctx, ` err := st.pool.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'), SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'),
count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'), count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'),
(SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1) (SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1),
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer) (SELECT error FROM analysis_jobs WHERE status = 'failed' AND error IS NOT NULL
GROUP BY error ORDER BY count(*) DESC LIMIT 1)
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer, &c.TopError)
if err != nil { if err != nil {
return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err) return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err)
} }
return c, nil return c, nil
} }
// RetryFailedAnalysis возвращает неудавшиеся задачи анализа в очередь с нуля попыток.
func (st *Store) RetryFailedAnalysis(ctx context.Context) (int, error) {
tag, err := st.pool.Exec(ctx, `
UPDATE analysis_jobs SET status = 'queued', attempts = 0, error = NULL, updated_at = now()
WHERE status = 'failed'`)
if err != nil {
return 0, fmt.Errorf("library: retry analysis: %w", err)
}
return int(tag.RowsAffected()), nil
}
// WaveMetrics — метрики волны за 7 дней (docs/recommendations.md#1-цели-и-как-измеряем-успех). // WaveMetrics — метрики волны за 7 дней (docs/recommendations.md#1-цели-и-как-измеряем-успех).
type WaveMetrics struct { type WaveMetrics struct {
Served, Sessions int Served, Sessions int
@@ -20,6 +20,7 @@ log = logging.getLogger(__name__)
MAX_ATTEMPTS = 3 MAX_ATTEMPTS = 3
MAX_DURATION_S = 15 * 60 MAX_DURATION_S = 15 * 60
IDLE_SLEEP_S = 10.0 IDLE_SLEEP_S = 10.0
MISSING_SLEEP_S = 60.0 # файлы пропали целиком — ждём, пока смонтируют диск
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -168,6 +169,18 @@ class Worker:
) )
conn.commit() conn.commit()
def postpone(self, conn: psycopg.Connection[Any], track_ids: list[str]) -> None:
"""Файла нет на диске (не смонтирован?) — обратно в очередь, попытка не засчитывается."""
conn.execute(
"""
UPDATE analysis_jobs SET status = 'queued', attempts = greatest(attempts - 1, 0),
error = 'file not found — is the disk mounted?', updated_at = now()
WHERE track_id = ANY(%s::uuid[])
""",
(track_ids,),
)
conn.commit()
def requeue_stale(self, conn: psycopg.Connection[Any]) -> None: def requeue_stale(self, conn: psycopg.Connection[Any]) -> None:
"""Задачи, брошенные упавшим воркером, и признаки старой версии анализатора — снова в очередь.""" """Задачи, брошенные упавшим воркером, и признаки старой версии анализатора — снова в очередь."""
conn.execute( conn.execute(
@@ -189,6 +202,16 @@ class Worker:
self.requeue_stale(conn) self.requeue_stale(conn)
while True: while True:
jobs = self.claim(conn, self.workers * 2) jobs = self.claim(conn, self.workers * 2)
missing = [j for j in jobs if not Path(j.path).exists()]
if missing:
self.postpone(conn, [j.track_id for j in missing])
jobs = [j for j in jobs if j not in missing]
if not jobs:
log.warning(
"analysis: %d files not found, waiting — is the disk mounted?", len(missing)
)
time.sleep(MISSING_SLEEP_S)
continue
if not jobs: if not jobs:
time.sleep(IDLE_SLEEP_S) time.sleep(IDLE_SLEEP_S)
continue continue