Compare commits

..
6 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5.5 66f5749fc1 fix(deploy): take the Gitea address for Android updates from git remote
ci / api / api (push) Successful in 22s
ci / db / db (push) Successful in 2m4s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m11s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m33s
The server kept the git.example.lan placeholder from .env.example, so the
/android page and in-app updates had no release. install.sh and update.sh now
fill GITEA_URL and GITEA_REPO from the clone's origin (https, ssh and scp
forms, credentials stripped) when they are empty or still the placeholder,
and the API treats the placeholder as not configured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:23:28 +03:00
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
41 changed files with 978 additions and 307 deletions

No files matched your search

+17
View File
@@ -54,6 +54,23 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
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
working-directory: services/api
run: go test -tags integration -count=1 -v ./internal/dbtest/
+29 -1
View File
@@ -1006,6 +1006,24 @@ paths:
$ref: "#/components/schemas/SearchResults"
default:
$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:
post:
operationId: reindexSearch
@@ -1379,6 +1397,7 @@ paths:
get:
operationId: getAndroidLatest
tags: [app]
security: [] # публично: страница /android раздаёт приложение без входа
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
description: |
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
@@ -1396,6 +1415,7 @@ paths:
get:
operationId: downloadAndroidApk
tags: [app]
security: []
summary: APK версии (кэшируется на сервере, поддерживает Range для докачки)
parameters:
- name: version
@@ -1994,6 +2014,10 @@ components:
web_player:
type: string
enum: [bar, island]
search_position:
type: string
enum: [center, left, right]
description: Где в топбаре веба поиск (по умолчанию center)
mobile_player:
type: string
enum: [cover, wave]
@@ -3081,7 +3105,7 @@ components:
description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители)
AnalysisStatus:
type: object
required: [queued, running, done, failed, analyzer_ver]
required: [queued, running, done, failed, analyzer_ver, top_error]
properties:
queued:
type: integer
@@ -3094,6 +3118,10 @@ components:
analyzer_ver:
type: string
nullable: true
top_error:
type: string
nullable: true
description: Самая частая ошибка среди неудавшихся — подсказка, что чинить
WaveMetrics:
type: object
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 { CollectionPage } from '../pages/CollectionPage';
import { HomePage } from '../pages/HomePage';
import { AppDownloadPage } from '../pages/AppDownloadPage';
import { LoginPage } from '../pages/LoginPage';
import { SearchPage, type SearchType } from '../pages/SearchPage';
import { SettingsPage } from '../pages/SettingsPage';
@@ -59,6 +60,9 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
errorComponent: RouteError,
});
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
@@ -233,6 +237,7 @@ const adminSettingsRoute = createRoute({
});
const routeTree = rootRoute.addChildren([
androidAppRoute,
loginRoute,
setupAdminRoute,
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 (
<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>
<p className={s.lead}>{t('login.subtitle')}</p>
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
+13
View File
@@ -126,6 +126,19 @@ function AppearanceSection() {
]}
/>
</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}>
<span>{t('settings.ambient')}</span>
<Segmented<'on' | 'off'>
+19 -2
View File
@@ -1,5 +1,5 @@
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 { useTranslation } from 'react-i18next';
@@ -32,6 +32,11 @@ export function LibraryPage() {
const reindex = useMutation({
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 tiles = d
? ([
@@ -77,7 +82,19 @@ export function LibraryPage() {
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>}
</section>
)}
@@ -118,6 +118,20 @@
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 {
height: 6px;
border-radius: 3px;
+77
View File
@@ -161,6 +161,83 @@
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 {
align-self: center;
}
+1
View File
@@ -7,6 +7,7 @@
gap: 28px;
padding: 24px 0;
text-align: center;
user-select: none; /* главная — только элементы управления волной */
}
.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 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 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 editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
+13
View File
@@ -41,6 +41,19 @@ button {
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 {
outline: 2px solid var(--accent);
outline-offset: 2px;
+17 -3
View File
@@ -31,10 +31,11 @@
.top {
grid-area: top;
z-index: 30;
display: flex;
/* Три зоны: логотип, середина, профиль; поиск — в одной из них (настройка «Расположение поиска»). */
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 24px;
justify-content: space-between;
gap: 16px;
padding: 0 20px;
border-bottom: 1px solid var(--line);
background: var(--bar-a);
@@ -53,9 +54,22 @@
}
}
.topLeft {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.topCenter {
display: flex;
justify-content: center;
}
.topRight {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
+12 -4
View File
@@ -22,6 +22,7 @@ import {
dislikeFillIcon,
dislikeIcon,
settingsIcon,
androidIcon,
storageFillIcon,
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
@@ -92,6 +93,8 @@ function Shell() {
const name = user.display_name ?? user.username;
const staff = user.role !== 'user';
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 pending = usePendingUploads(user.role === 'admin');
@@ -99,11 +102,15 @@ function Shell() {
<div className={cx(s.shell, island && s.island)}>
{user.ui_prefs.ambient !== false && <ShellAmbient />}
<header className={s.top}>
<Link to="/" className={s.logoLink}>
<Logo />
</Link>
<SearchBox />
<div className={s.topLeft}>
<Link to="/" className={s.logoLink}>
<Logo />
</Link>
{searchAt === 'left' && search}
</div>
<div className={s.topCenter}>{searchAt === 'center' && search}</div>
<div className={s.topRight}>
{searchAt === 'right' && search}
<Menu
label={t('profile.menu')}
triggerClassName={s.profileButton}
@@ -122,6 +129,7 @@ function Shell() {
}
items={[
{ 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() },
]}
/>
+47 -6
View File
@@ -1,12 +1,17 @@
.box {
position: relative;
flex: 1;
max-width: 520px;
flex: none;
width: 40px;
transition: width 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.expanded {
width: min(520px, 46vw);
}
.icon {
position: absolute;
left: 14px;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: var(--text3);
@@ -16,13 +21,36 @@
.input {
width: 100%;
height: 40px;
padding: 0 40px 0 42px;
padding: 0 40px 0 40px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
background: var(--surface2);
color: var(--text);
font: inherit;
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 {
@@ -56,8 +84,7 @@
.popover {
position: absolute;
left: 0;
right: 0;
width: min(520px, calc(100vw - 32px));
top: calc(100% + 8px);
z-index: 45;
max-height: min(70vh, 560px);
@@ -70,6 +97,20 @@
animation: pipuIn 0.15s ease-out;
}
/* Подсказки раскрываются в сторону свободного места: слева — вправо, справа — влево, по центру — от середины. */
.left .popover {
left: 0;
}
.right .popover {
right: 0;
}
.center .popover {
left: 50%;
transform: translateX(-50%);
}
.corrected {
margin: 4px 10px 8px;
font-size: 13px;
+20 -5
View File
@@ -23,8 +23,11 @@ type Item = {
go: () => void;
};
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */
export function SearchBox() {
/**
* Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент.
* Без фокуса и текста свёрнут в кнопку с лупой; поле раскрывается влево с анимацией.
*/
export function SearchBox({ align = 'center' }: { align?: 'left' | 'center' | 'right' }) {
const { t } = useTranslation();
const session = useSession();
const navigate = useNavigate();
@@ -34,6 +37,10 @@ export function SearchBox() {
const [debounced, setDebounced] = useState('');
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const [focused, setFocused] = useState(false);
const blurTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
// Свёрнут в круглую кнопку, пока не нужен: раскрывается по клику, «/» или Ctrl+K.
const expanded = focused || q !== '';
useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
@@ -141,7 +148,7 @@ export function SearchBox() {
const optionId = (i: number) => `${listId}-${i}`;
return (
<div className={s.box}>
<div className={cx(s.box, expanded && s.expanded, s[align])}>
<Icon src={searchIcon} size={18} className={s.icon} />
<input
ref={inputRef}
@@ -160,8 +167,16 @@ export function SearchBox() {
setOpen(true);
setActive(-1);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(close, 120)}
onFocus={() => {
// Вернулись в поле раньше, чем сработало закрытие по blur, — подсказки не прячем.
clearTimeout(blurTimer.current);
setFocused(true);
setOpen(true);
}}
onBlur={() => {
setFocused(false);
blurTimer.current = setTimeout(close, 120);
}}
onKeyDown={onKeyDown}
/>
<kbd className={s.kbd} aria-hidden>
+4 -2
View File
@@ -45,6 +45,8 @@ SUPPORTED_LOCALES=ru,en
MIN_ANDROID_VERSION_CODE=0
# --- Самообновление Android (M5) ---
GITEA_URL=https://git.example.lan
GITEA_REPO=owner/pipu-music
# Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music).
# Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000
GITEA_URL=
GITEA_REPO=
GITEA_TOKEN=
+33
View File
@@ -76,6 +76,39 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
fi
done
# Самообновление Android: адрес Gitea и репозиторий — из origin этого клона, если в .env пусто
# или осталась заглушка из .env.example (https://host/owner/repo, git@host:owner/repo, ssh://git@host:port/owner/repo).
fill_gitea() {
local url repo origin host path
url="$(sed -n 's/^GITEA_URL=//p' .env)"
repo="$(sed -n 's/^GITEA_REPO=//p' .env)"
case "$url" in "" | *example.*) url="" ;; esac
case "$repo" in "" | owner/*) repo="" ;; esac
[ -n "$url" ] && [ -n "$repo" ] && return 0
origin="$(git -c safe.directory='*' remote get-url origin 2>/dev/null || true)"
[ -n "$origin" ] || return 0
case "$origin" in
http://* | https://*)
host="$(printf '%s' "$origin" | sed -E 's#^(https?://)([^@/]*@)?([^/]+)/.*#\1\3#')"
path="$(printf '%s' "$origin" | sed -E 's#^https?://[^/]+/##')" ;;
ssh://*)
host="https://$(printf '%s' "$origin" | sed -E 's#^ssh://([^@/]*@)?([^:/]+).*#\2#')"
path="$(printf '%s' "$origin" | sed -E 's#^ssh://[^/]+/##')" ;;
*:*)
host="https://$(printf '%s' "$origin" | sed -E 's#^([^@]*@)?([^:]+):.*#\2#')"
path="${origin#*:}" ;;
*) return 0 ;;
esac
path="${path%.git}"
[ -n "$url" ] || url="$host"
[ -n "$repo" ] || repo="$path"
for kv in "GITEA_URL=$url" "GITEA_REPO=$repo"; do
if grep -q "^${kv%%=*}=" .env; then sed -i "s|^${kv%%=*}=.*|$kv|" .env; else echo "$kv" >> .env; fi
done
echo "→ самообновление Android: $url, $repo (из git remote; поменять — в .env)"
}
fill_gitea
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
if [ -n "$LIBRARY_HOST" ]; then
if grep -q '^LIBRARY_HOST=' .env; then
+33
View File
@@ -26,6 +26,39 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
fi
done
# Самообновление Android: адрес Gitea и репозиторий — из origin этого клона, если в .env пусто
# или осталась заглушка из .env.example (https://host/owner/repo, git@host:owner/repo, ssh://git@host:port/owner/repo).
fill_gitea() {
local url repo origin host path
url="$(sed -n 's/^GITEA_URL=//p' .env)"
repo="$(sed -n 's/^GITEA_REPO=//p' .env)"
case "$url" in "" | *example.*) url="" ;; esac
case "$repo" in "" | owner/*) repo="" ;; esac
[ -n "$url" ] && [ -n "$repo" ] && return 0
origin="$(git -c safe.directory='*' remote get-url origin 2>/dev/null || true)"
[ -n "$origin" ] || return 0
case "$origin" in
http://* | https://*)
host="$(printf '%s' "$origin" | sed -E 's#^(https?://)([^@/]*@)?([^/]+)/.*#\1\3#')"
path="$(printf '%s' "$origin" | sed -E 's#^https?://[^/]+/##')" ;;
ssh://*)
host="https://$(printf '%s' "$origin" | sed -E 's#^ssh://([^@/]*@)?([^:/]+).*#\2#')"
path="$(printf '%s' "$origin" | sed -E 's#^ssh://[^/]+/##')" ;;
*:*)
host="https://$(printf '%s' "$origin" | sed -E 's#^([^@]*@)?([^:]+):.*#\2#')"
path="${origin#*:}" ;;
*) return 0 ;;
esac
path="${path%.git}"
[ -n "$url" ] || url="$host"
[ -n "$repo" ] || repo="$path"
for kv in "GITEA_URL=$url" "GITEA_REPO=$repo"; do
if grep -q "^${kv%%=*}=" .env; then sed -i "s|^${kv%%=*}=.*|$kv|" .env; else echo "$kv" >> .env; fi
done
echo "→ самообновление Android: $url, $repo (из git remote; поменять — в .env)"
}
fill_gitea
# Папка с музыкой пуста — почти всегда это несмонтированный диск: api отдавал бы ошибки воспроизведения.
LIBRARY_HOST="$(sed -n 's/^LIBRARY_HOST=//p' .env)"
if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [ "${PIPU_ALLOW_EMPTY_LIBRARY:-}" != 1 ]; then
+5
View File
@@ -49,6 +49,9 @@
индекс обновляется сам и пересобирается при старте api).
- **Аудиоанализ** (темп, энергия, настроение — для «Моей волны») идёт в фоне: на 10 тыс. треков — несколько часов
при 4 ядрах (`analyzer` в `compose.yaml`, `--workers`). Волна работает и до его окончания, но точнее — после.
Не удалось проанализировать часть треков (например, пока диск не был смонтирован) — «Повторить» под счётчиком
ошибок возвращает их в очередь; рядом показана самая частая ошибка. Пропавшие файлы анализатор не считает
попыткой и ждёт, пока диск вернётся.
## 4. Очередь на добавление
@@ -91,6 +94,8 @@
прочитайте заметки к релизу на странице релизов Gitea.
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
## 7. Бэкапы и восстановление
+1 -1
View File
@@ -77,7 +77,7 @@
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
| `TRANSCODE_CONCURRENCY` | `2` | |
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | `https://git.home.lan`, `owner/pipu-music`, read-only токен | Для самообновления Android |
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/android` |
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
| `LOG_LEVEL` | `info` | |
+4 -2
View File
@@ -250,8 +250,10 @@ curl -s https://music.example.com/readyz
приложение попросит обновиться.
Дальше приложение обновляется само: с версии M5 оно проверяет новые релизы через ваш сервер
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Для этого в `.env` сервера нужно заполнить
`GITEA_URL`, `GITEA_REPO` и `GITEA_TOKEN` (токен только на чтение).
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
ссылка `https://<ваш-домен>/android`.
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
+2
View File
@@ -82,6 +82,8 @@
## Самообновление
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
Приложение распространяется только через Gitea Releases (не Google Play).
**Поток:**
@@ -880,6 +880,23 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -1492,6 +1509,11 @@ export interface components {
wave_variant?: "orb" | "liquid";
/** @enum {string} */
web_player?: "bar" | "island";
/**
* @description Где в топбаре веба поиск (по умолчанию center)
* @enum {string}
*/
search_position?: "center" | "left" | "right";
/** @enum {string} */
mobile_player?: "cover" | "wave";
ambient?: boolean;
@@ -2012,6 +2034,8 @@ export interface components {
done: number;
failed: number;
analyzer_ver: string | null;
/** @description Самая частая ошибка среди неудавшихся — подсказка, что чинить */
top_error: string | null;
};
/** @description Метрики волны за 7 дней */
WaveMetrics: {
@@ -3355,6 +3379,29 @@ export interface operations {
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: {
parameters: {
query?: never;
+2 -1
View File
@@ -160,7 +160,8 @@
"wave_early_skip": "Early skips",
"wave_likes": "Likes per 100",
"wave_model": "Model version",
"wave_by_model": "Wave model versions compared"
"wave_by_model": "Wave model versions compared",
"analysis_retry": "Retry"
},
"write_template": {
"title": "Uploads and path template",
+14
View File
@@ -20,5 +20,19 @@
"min_length": "At least {{min}} characters",
"submit": "Save and continue",
"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": {
"menu": "Profile menu",
"settings": "Settings",
"logout": "Sign out"
"logout": "Sign out",
"android_app": "Android app"
},
"roles": {
"admin": "Administrator",
@@ -114,7 +115,11 @@
"player_island": "Island",
"ambient": "Ambient background",
"on": "On",
"off": "Off"
"off": "Off",
"search_position": "Search position",
"search_left": "Left",
"search_center": "Center",
"search_right": "Right"
},
"form": {
"show_password": "Show password",
+2 -1
View File
@@ -160,7 +160,8 @@
"wave_early_skip": "Ранние скипы",
"wave_likes": "Лайков на 100",
"wave_model": "Версия модели",
"wave_by_model": "Сравнение версий модели волны"
"wave_by_model": "Сравнение версий модели волны",
"analysis_retry": "Повторить"
},
"write_template": {
"title": "Приём загрузок и шаблон пути",
+14
View File
@@ -20,5 +20,19 @@
"min_length": "Не короче {{min}} символов",
"submit": "Сохранить и продолжить",
"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": {
"menu": "Меню профиля",
"settings": "Настройки",
"logout": "Выйти"
"logout": "Выйти",
"android_app": "Приложение для Android"
},
"roles": {
"admin": "Администратор",
@@ -114,7 +115,11 @@
"player_island": "Остров",
"ambient": "Живой фон",
"on": "Вкл.",
"off": "Выкл."
"off": "Выкл.",
"search_position": "Расположение поиска",
"search_left": "Слева",
"search_center": "По центру",
"search_right": "Справа"
},
"form": {
"show_password": "Показать пароль",
+353 -257
View File
@@ -137,6 +137,13 @@ const (
UiPrefsMobilePlayerWave UiPrefsMobilePlayer = "wave"
)
// Defines values for UiPrefsSearchPosition.
const (
Center UiPrefsSearchPosition = "center"
Left UiPrefsSearchPosition = "left"
Right UiPrefsSearchPosition = "right"
)
// Defines values for UiPrefsTheme.
const (
Dark UiPrefsTheme = "dark"
@@ -288,6 +295,9 @@ type AnalysisStatus struct {
Failed int `json:"failed"`
Queued int `json:"queued"`
Running int `json:"running"`
// TopError Самая частая ошибка среди неудавшихся — подсказка, что чинить
TopError *string `json:"top_error"`
}
// AndroidRelease defines model for AndroidRelease.
@@ -907,14 +917,20 @@ type UiPrefs struct {
CrossfadeS *int `json:"crossfade_s,omitempty"`
MobilePlayer *UiPrefsMobilePlayer `json:"mobile_player,omitempty"`
Normalize *bool `json:"normalize,omitempty"`
Theme *UiPrefsTheme `json:"theme,omitempty"`
WaveVariant *UiPrefsWaveVariant `json:"wave_variant,omitempty"`
WebPlayer *UiPrefsWebPlayer `json:"web_player,omitempty"`
// SearchPosition Где в топбаре веба поиск (по умолчанию center)
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.
type UiPrefsMobilePlayer string
// UiPrefsSearchPosition Где в топбаре веба поиск (по умолчанию center)
type UiPrefsSearchPosition string
// UiPrefsTheme defines model for UiPrefs.Theme.
type UiPrefsTheme string
@@ -1464,6 +1480,9 @@ type ServerInterface interface {
// Журнал действий (admin), новые сверху
// (GET /api/v1/admin/audit)
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)
// (GET /api/v1/admin/library/errors)
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)
}
// Вернуть в очередь треки, которые не удалось проанализировать (admin)
// (POST /api/v1/admin/library/analysis/retry)
func (_ Unimplemented) RetryAnalysis(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// Файлы, которые не удалось прочитать (admin)
// (GET /api/v1/admin/library/errors)
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)
}
// 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
func (siw *ServerInterfaceWrapper) ListLibraryErrors(w http.ResponseWriter, r *http.Request) {
@@ -3057,12 +3102,6 @@ func (siw *ServerInterfaceWrapper) DownloadAndroidApk(w http.ResponseWriter, r *
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) {
siw.Handler.DownloadAndroidApk(w, r, version)
}))
@@ -3077,12 +3116,6 @@ func (siw *ServerInterfaceWrapper) DownloadAndroidApk(w http.ResponseWriter, r *
// GetAndroidLatest operation middleware
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) {
siw.Handler.GetAndroidLatest(w, r)
}))
@@ -4455,6 +4488,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
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.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)
}
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 {
Params ListLibraryErrorsParams
}
@@ -6832,6 +6898,9 @@ type StrictServerInterface interface {
// Журнал действий (admin), новые сверху
// (GET /api/v1/admin/audit)
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)
// (GET /api/v1/admin/library/errors)
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
func (sh *strictHandler) ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams) {
var request ListLibraryErrorsRequestObject
@@ -9130,248 +9223,251 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
}
// GetSwagger returns the content of the embedded swagger specification file
+3 -1
View File
@@ -100,8 +100,10 @@ func NewHandler(cfg Config, log *slog.Logger) *Handler {
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
}
// configured — адрес и репозиторий заданы, и это не заглушка из .env.example.
func (h *Handler) configured() bool {
return h.cfg.GiteaURL != "" && h.cfg.Repo != ""
return h.cfg.GiteaURL != "" && h.cfg.Repo != "" &&
!strings.Contains(h.cfg.GiteaURL, "example.") && !strings.HasPrefix(h.cfg.Repo, "owner/")
}
func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) {
+1
View File
@@ -565,5 +565,6 @@ func TestPublicRoutes(t *testing.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, "/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)
}
+9 -7
View File
@@ -241,13 +241,14 @@ func MeDTO(u User) gen.Me {
func uiPrefsDTO(p UIPrefs) gen.UiPrefs {
return gen.UiPrefs{
Theme: (*gen.UiPrefsTheme)(p.Theme),
WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant),
WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer),
MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
Theme: (*gen.UiPrefsTheme)(p.Theme),
WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant),
WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer),
SearchPosition: (*gen.UiPrefsSearchPosition)(p.SearchPos),
MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
}
}
@@ -256,6 +257,7 @@ func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
Theme: (*string)(p.Theme),
WaveVariant: (*string)(p.WaveVariant),
WebPlayer: (*string)(p.WebPlayer),
SearchPos: (*string)(p.SearchPosition),
MobilePlayer: (*string)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
+4 -1
View File
@@ -55,6 +55,8 @@ var publicRoutes = map[string]bool{
"GET /api/v1/server-info": true,
"POST /api/v1/auth/login": 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 — ссылку защищает подпись.
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
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 {
next.ServeHTTP(w, r.WithContext(ctx))
return
+1
View File
@@ -37,6 +37,7 @@ type UIPrefs struct {
Theme *string `json:"theme,omitempty"`
WaveVariant *string `json:"wave_variant,omitempty"`
WebPlayer *string `json:"web_player,omitempty"`
SearchPos *string `json:"search_position,omitempty"`
MobilePlayer *string `json:"mobile_player,omitempty"`
Ambient *bool `json:"ambient,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{
"theme": {"dark", "light", "system"},
"wave_variant": {"orb", "liquid"},
"web_player": {"bar", "island"},
"mobile_player": {"cover", "wave"},
"theme": {"dark", "light", "system"},
"wave_variant": {"orb", "liquid"},
"web_player": {"bar", "island"},
"search_position": {"center", "left", "right"},
"mobile_player": {"cover", "wave"},
}
// ValidateUIPrefs проверяет значения перечислений и диапазон кроссфейда.
@@ -107,6 +108,7 @@ func ValidateUIPrefs(p UIPrefs) error {
}
for name, v := range map[string]*string{
"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 {
return err
@@ -130,6 +132,9 @@ func MergeUIPrefs(base, patch UIPrefs) UIPrefs {
if patch.WebPlayer != nil {
base.WebPlayer = patch.WebPlayer
}
if patch.SearchPos != nil {
base.SearchPos = patch.SearchPos
}
if patch.MobilePlayer != nil {
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
}
// 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.
func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRequestObject) (gen.GetLibraryStatusResponseObject, error) {
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{},
Analysis: gen.AnalysisStatus{
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{
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 запускает скан источника в фоне.
// 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) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return Progress{}, err
+16 -3
View File
@@ -605,7 +605,7 @@ func (st *Store) AlbumCoverPath(ctx context.Context, albumID uuid.UUID) (*string
// AnalysisCounts — состояние очереди аудиоанализа.
type AnalysisCounts struct {
Queued, Running, Done, Failed int
AnalyzerVer *string
AnalyzerVer, TopError *string
}
// AnalysisCounts считает задачи анализа по статусам и текущую версию анализатора.
@@ -614,14 +614,27 @@ func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
err := st.pool.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'),
count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'),
(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 analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1),
(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 {
return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err)
}
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-цели-и-как-измеряем-успех).
type WaveMetrics struct {
Served, Sessions int
@@ -20,6 +20,7 @@ log = logging.getLogger(__name__)
MAX_ATTEMPTS = 3
MAX_DURATION_S = 15 * 60
IDLE_SLEEP_S = 10.0
MISSING_SLEEP_S = 60.0 # файлы пропали целиком — ждём, пока смонтируют диск
@dataclass(frozen=True)
@@ -168,6 +169,18 @@ class Worker:
)
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:
"""Задачи, брошенные упавшим воркером, и признаки старой версии анализатора — снова в очередь."""
conn.execute(
@@ -189,6 +202,16 @@ class Worker:
self.requeue_stale(conn)
while True:
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:
time.sleep(IDLE_SLEEP_S)
continue