Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73e642c1ec | ||
|
|
66f5749fc1 | ||
|
|
823d1eb841 | ||
|
|
860072e542 | ||
|
|
d2f17d8d62 | ||
|
|
4763339335 | ||
|
|
fda6ed48ab | ||
|
|
2d49cb3f19 |
No files matched your search
@@ -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/
|
||||||
|
|||||||
@@ -143,6 +143,11 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
./gradlew --stop
|
./gradlew --stop
|
||||||
|
|
||||||
|
# Только arm64-v8a, все нужные .so на месте и выровнены по 16 КБ — иначе APK падает при запуске
|
||||||
|
# на части устройств (Android 15+ с 16 КБ страницами, эмуляторы x86).
|
||||||
|
- name: Проверка APK (ABI, 16 КБ страницы)
|
||||||
|
run: node scripts/check-apk.mjs apps/mobile/android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
|
||||||
- name: Диагностика сбоя (диск, память, OOM)
|
- name: Диагностика сбоя (диск, память, OOM)
|
||||||
if: failure()
|
if: failure()
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+29
-1
@@ -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]
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
|
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
|
||||||
// Android по умолчанию запрещает незашифрованный HTTP.
|
// Android по умолчанию запрещает незашифрованный HTTP.
|
||||||
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
|
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
|
||||||
|
// APK только под arm64-v8a — та же ABI, под которую собираются нативные модули (plugins/withArm64Only.js).
|
||||||
|
'./plugins/withArm64Only',
|
||||||
],
|
],
|
||||||
extra: { versionCode },
|
extra: { versionCode },
|
||||||
experiments: { typedRoutes: false },
|
experiments: { typedRoutes: false },
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Только arm64-v8a в APK. Наши нативные модули (Expo, Skia, Reanimated) собираются под одну ABI
|
||||||
|
// (-PreactNativeArchitectures=arm64-v8a в release.yml), а готовые библиотеки React Native приносят
|
||||||
|
// каталоги всех ABI. Без фильтра x86/x86_64-эмулятор и 32-битные устройства выбирают неполный каталог
|
||||||
|
// и падают при старте: «couldn't find DSO to load: libexpo-modules-core.so».
|
||||||
|
const { withAppBuildGradle } = require('expo/config-plugins');
|
||||||
|
|
||||||
|
const MARKER = '// pipu: arm64-v8a only';
|
||||||
|
|
||||||
|
module.exports = function withArm64Only(config) {
|
||||||
|
return withAppBuildGradle(config, (cfg) => {
|
||||||
|
if (cfg.modResults.contents.includes(MARKER)) return cfg;
|
||||||
|
cfg.modResults.contents = cfg.modResults.contents.replace(
|
||||||
|
/defaultConfig\s*\{/,
|
||||||
|
(m) => `${m}\n ${MARKER}\n ndk { abiFilters "arm64-v8a" }`,
|
||||||
|
);
|
||||||
|
return cfg;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -8,6 +8,8 @@ WORKDIR /repo
|
|||||||
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
|
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
|
||||||
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
|
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
# pnpm-патчи зависимостей (lockfile ссылается на них, без них --frozen-lockfile падает)
|
||||||
|
COPY patches/ patches/
|
||||||
COPY apps/web/package.json apps/web/
|
COPY apps/web/package.json apps/web/
|
||||||
COPY apps/mobile/package.json apps/mobile/
|
COPY apps/mobile/package.json apps/mobile/
|
||||||
COPY packages/api-client/package.json packages/api-client/
|
COPY packages/api-client/package.json packages/api-client/
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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'>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+4
-2
@@ -45,6 +45,8 @@ SUPPORTED_LOCALES=ru,en
|
|||||||
MIN_ANDROID_VERSION_CODE=0
|
MIN_ANDROID_VERSION_CODE=0
|
||||||
|
|
||||||
# --- Самообновление Android (M5) ---
|
# --- Самообновление Android (M5) ---
|
||||||
GITEA_URL=https://git.example.lan
|
# Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music).
|
||||||
GITEA_REPO=owner/pipu-music
|
# Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000
|
||||||
|
GITEA_URL=
|
||||||
|
GITEA_REPO=
|
||||||
GITEA_TOKEN=
|
GITEA_TOKEN=
|
||||||
@@ -76,6 +76,39 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
|
|||||||
fi
|
fi
|
||||||
done
|
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
|
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
|
||||||
if [ -n "$LIBRARY_HOST" ]; then
|
if [ -n "$LIBRARY_HOST" ]; then
|
||||||
if grep -q '^LIBRARY_HOST=' .env; then
|
if grep -q '^LIBRARY_HOST=' .env; then
|
||||||
|
|||||||
@@ -26,6 +26,39 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
|
|||||||
fi
|
fi
|
||||||
done
|
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 отдавал бы ошибки воспроизведения.
|
# Папка с музыкой пуста — почти всегда это несмонтированный диск: api отдавал бы ошибки воспроизведения.
|
||||||
LIBRARY_HOST="$(sed -n 's/^LIBRARY_HOST=//p' .env)"
|
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
|
if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [ "${PIPU_ALLOW_EMPTY_LIBRARY:-}" != 1 ]; then
|
||||||
|
|||||||
@@ -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. Бэкапы и восстановление
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@
|
|||||||
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
|
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
|
||||||
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
|
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
|
||||||
| `TRANSCODE_CONCURRENCY` | `2` | |
|
| `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` | Владелец файлов, которые агент записывает в принимающие хранилища |
|
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
|
||||||
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
|
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
|
||||||
| `LOG_LEVEL` | `info` | |
|
| `LOG_LEVEL` | `info` | |
|
||||||
|
|||||||
@@ -250,8 +250,10 @@ curl -s https://music.example.com/readyz
|
|||||||
приложение попросит обновиться.
|
приложение попросит обновиться.
|
||||||
|
|
||||||
Дальше приложение обновляется само: с версии M5 оно проверяет новые релизы через ваш сервер
|
Дальше приложение обновляется само: с версии M5 оно проверяет новые релизы через ваш сервер
|
||||||
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Для этого в `.env` сервера нужно заполнить
|
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
|
||||||
`GITEA_URL`, `GITEA_REPO` и `GITEA_TOKEN` (токен только на чтение).
|
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
|
||||||
|
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
|
||||||
|
ссылка `https://<ваш-домен>/android`.
|
||||||
|
|
||||||
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
|
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
|
||||||
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
|
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
|
||||||
|
|||||||
@@ -16,6 +16,18 @@
|
|||||||
| i18n | i18next, словари из `packages/i18n` |
|
| i18n | i18next, словари из `packages/i18n` |
|
||||||
| Минимальная версия | Android 8.0 (API 26), target — актуальный API |
|
| Минимальная версия | Android 8.0 (API 26), target — актуальный API |
|
||||||
|
|
||||||
|
## Сборка APK: ABI и 16 КБ страницы
|
||||||
|
|
||||||
|
- APK только под `arm64-v8a` (`plugins/withArm64Only.js`): нативные модули собираются под одну ABI, а без фильтра
|
||||||
|
в APK попадали неполные каталоги `x86`, `x86_64`, `armeabi-v7a` из библиотек React Native — эмулятор x86 и
|
||||||
|
32-битные устройства выбирали их и падали при старте (`couldn't find DSO to load: libexpo-modules-core.so`).
|
||||||
|
Эмулятор для проверки — образ arm64 или настоящее устройство.
|
||||||
|
- Все `.so` выровнены по 16 КБ: устройства с Android 15+ и 16 КБ страницами памяти не загружают библиотеки
|
||||||
|
с выравниванием 4 КБ, приложение закрывается сразу после запуска. Skia 2.0.0-next.4 собиралась без
|
||||||
|
`ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES` — флаг добавлен pnpm-патчем (`patches/`).
|
||||||
|
- `scripts/check-apk.mjs` в релизе проверяет ABI, наличие ключевых библиотек и выравнивание; без этого релиз
|
||||||
|
не публикуется.
|
||||||
|
|
||||||
## Экраны
|
## Экраны
|
||||||
|
|
||||||
Макет — `Pipu Android.dc.html`, сводка решений — [design.md](design.md#android).
|
Макет — `Pipu Android.dc.html`, сводка решений — [design.md](design.md#android).
|
||||||
@@ -82,6 +94,8 @@
|
|||||||
|
|
||||||
## Самообновление
|
## Самообновление
|
||||||
|
|
||||||
|
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
|
||||||
|
|
||||||
Приложение распространяется только через Gitea Releases (не Google Play).
|
Приложение распространяется только через Gitea Releases (не Google Play).
|
||||||
|
|
||||||
**Поток:**
|
**Поток:**
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"generate": "turbo run generate",
|
"generate": "turbo run generate",
|
||||||
"i18n:check": "pnpm --filter @pipu/i18n check",
|
"i18n:check": "pnpm --filter @pipu/i18n check",
|
||||||
"version:info": "node scripts/version.mjs",
|
"version:info": "node scripts/version.mjs",
|
||||||
"test:scripts": "node --test scripts/version.test.mjs scripts/release.test.mjs"
|
"test:scripts": "node --test scripts/version.test.mjs scripts/release.test.mjs scripts/check-apk.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"turbo": "2.5.4",
|
"turbo": "2.5.4",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Приём загрузок и шаблон пути",
|
||||||
|
|||||||
@@ -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": "Войти в веб-версию"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "Показать пароль",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
diff --git a/android/build.gradle b/android/build.gradle
|
||||||
|
index c5431ee7db4fccb4a67fc790ef95db00b5b545dc..bc5feca88bf56c0bf5ac5c4b03f8ab57b693eef5 100644
|
||||||
|
--- a/android/build.gradle
|
||||||
|
+++ b/android/build.gradle
|
||||||
|
@@ -156,7 +156,9 @@ android {
|
||||||
|
arguments '-DANDROID_STL=c++_shared',
|
||||||
|
"-DREACT_NATIVE_VERSION=${REACT_NATIVE_VERSION}",
|
||||||
|
"-DNODE_MODULES_DIR=${nodeModules}",
|
||||||
|
- "-DPREBUILT_DIR=${prebuiltDir}"
|
||||||
|
+ "-DPREBUILT_DIR=${prebuiltDir}",
|
||||||
|
+ // 16 КБ страницы памяти (Android 15+): иначе librnskia.so не загружается на таких устройствах
|
||||||
|
+ "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5
-2
@@ -4,6 +4,9 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
patchedDependencies:
|
||||||
|
'@shopify/react-native-skia@2.0.0-next.4': ae341532dba11d4a93999214787e561932c027da2a86640d9f1bcad8ba5910b7
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -37,7 +40,7 @@ importers:
|
|||||||
version: 11.4.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
|
version: 11.4.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
|
||||||
'@shopify/react-native-skia':
|
'@shopify/react-native-skia':
|
||||||
specifier: 2.0.0-next.4
|
specifier: 2.0.0-next.4
|
||||||
version: 2.0.0-next.4(react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
version: 2.0.0-next.4(patch_hash=ae341532dba11d4a93999214787e561932c027da2a86640d9f1bcad8ba5910b7)(react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.104.4
|
specifier: ^5.104.4
|
||||||
version: 5.104.4(@types/react@19.0.14)(react@19.0.0)
|
version: 5.104.4(@types/react@19.0.14)(react@19.0.0)
|
||||||
@@ -5396,7 +5399,7 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.64.10':
|
'@rollup/rollup-win32-x64-msvc@4.64.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@shopify/react-native-skia@2.0.0-next.4(react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)':
|
'@shopify/react-native-skia@2.0.0-next.4(patch_hash=ae341532dba11d4a93999214787e561932c027da2a86640d9f1bcad8ba5910b7)(react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
canvaskit-wasm: 0.40.0
|
canvaskit-wasm: 0.40.0
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ packages:
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
|
||||||
|
patchedDependencies:
|
||||||
|
'@shopify/react-native-skia@2.0.0-next.4': patches/@shopify__react-native-skia@2.0.0-next.4.patch
|
||||||
|
|
||||||
# Изолированная раскладка node_modules (pnpm 11). Expo вычисляет react-native.config.js пакета expo
|
# Изолированная раскладка node_modules (pnpm 11). Expo вычисляет react-native.config.js пакета expo
|
||||||
# по пути через симлинк apps/mobile/node_modules/expo, и оттуда require('expo-modules-autolinking')
|
# по пути через симлинк apps/mobile/node_modules/expo, и оттуда require('expo-modules-autolinking')
|
||||||
# находится только в корневом node_modules. Без этого autolinking молча генерирует неверный импорт
|
# находится только в корневом node_modules. Без этого autolinking молча генерирует неверный импорт
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Проверка собранного APK перед публикацией (release.yml):
|
||||||
|
// - нативные библиотеки только под arm64-v8a: неполный каталог другой ABI роняет приложение при старте
|
||||||
|
// («couldn't find DSO to load: libexpo-modules-core.so» на x86-эмуляторе);
|
||||||
|
// - есть библиотеки, без которых приложение не запустится;
|
||||||
|
// - PT_LOAD-сегменты каждой .so выровнены по 16 КБ: иначе на устройствах с 16 КБ страницами памяти
|
||||||
|
// (Android 15+) библиотека не загружается и приложение закрывается сразу после запуска.
|
||||||
|
// Запуск: node scripts/check-apk.mjs app-release.apk
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { inflateRawSync } from 'node:zlib';
|
||||||
|
|
||||||
|
export const ABI = 'arm64-v8a';
|
||||||
|
export const REQUIRED = ['libreactnative.so', 'libhermes.so', 'libexpo-modules-core.so', 'librnskia.so', 'libreanimated.so'];
|
||||||
|
const PAGE = 16384;
|
||||||
|
|
||||||
|
/** Файлы ZIP по центральному каталогу: имя → содержимое. */
|
||||||
|
export function zipEntries(buf) {
|
||||||
|
let eocd = buf.length - 22;
|
||||||
|
while (eocd >= 0 && buf.readUInt32LE(eocd) !== 0x06054b50) eocd--;
|
||||||
|
if (eocd < 0) throw new Error('not a zip file');
|
||||||
|
const count = buf.readUInt16LE(eocd + 10);
|
||||||
|
let p = buf.readUInt32LE(eocd + 16);
|
||||||
|
const out = new Map();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const method = buf.readUInt16LE(p + 10);
|
||||||
|
const size = buf.readUInt32LE(p + 20);
|
||||||
|
const nameLen = buf.readUInt16LE(p + 28);
|
||||||
|
const extraLen = buf.readUInt16LE(p + 30);
|
||||||
|
const commentLen = buf.readUInt16LE(p + 32);
|
||||||
|
const local = buf.readUInt32LE(p + 42);
|
||||||
|
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
||||||
|
out.set(name, () => {
|
||||||
|
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
|
||||||
|
const raw = buf.subarray(start, start + size);
|
||||||
|
return method === 0 ? raw : inflateRawSync(raw);
|
||||||
|
});
|
||||||
|
p += 46 + nameLen + extraLen + commentLen;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Наименьшее выравнивание PT_LOAD-сегментов 64-битной ELF-библиотеки. */
|
||||||
|
export function loadAlignment(elf) {
|
||||||
|
if (elf.readUInt32BE(0) !== 0x7f454c46 || elf[4] !== 2) throw new Error('not a 64-bit ELF');
|
||||||
|
const phoff = Number(elf.readBigUInt64LE(0x20));
|
||||||
|
const phentsize = elf.readUInt16LE(0x36);
|
||||||
|
const phnum = elf.readUInt16LE(0x38);
|
||||||
|
let min = Infinity;
|
||||||
|
for (let i = 0; i < phnum; i++) {
|
||||||
|
const off = phoff + i * phentsize;
|
||||||
|
if (elf.readUInt32LE(off) === 1) min = Math.min(min, Number(elf.readBigUInt64LE(off + 0x30)));
|
||||||
|
}
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkApk(buf) {
|
||||||
|
const problems = [];
|
||||||
|
const entries = zipEntries(buf);
|
||||||
|
const libs = [...entries.keys()].filter((n) => n.startsWith('lib/') && n.endsWith('.so'));
|
||||||
|
const abis = new Set(libs.map((n) => n.split('/')[1]));
|
||||||
|
for (const abi of abis) if (abi !== ABI) problems.push(`лишняя ABI в APK: lib/${abi}/`);
|
||||||
|
const mine = new Set(libs.filter((n) => n.startsWith(`lib/${ABI}/`)).map((n) => n.split('/')[2]));
|
||||||
|
for (const lib of REQUIRED) if (!mine.has(lib)) problems.push(`нет lib/${ABI}/${lib}`);
|
||||||
|
for (const name of libs.filter((n) => n.startsWith(`lib/${ABI}/`))) {
|
||||||
|
const align = loadAlignment(entries.get(name)());
|
||||||
|
if (align < PAGE) problems.push(`${name}: сегменты выровнены по ${align} байт, нужно ${PAGE} (16 КБ страницы)`);
|
||||||
|
}
|
||||||
|
return { libs: mine.size, problems };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('check-apk.mjs')) {
|
||||||
|
const file = process.argv[2];
|
||||||
|
if (!file) {
|
||||||
|
console.error('usage: node scripts/check-apk.mjs <apk>');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const { libs, problems } = checkApk(readFileSync(file));
|
||||||
|
if (problems.length) {
|
||||||
|
for (const p of problems) console.error(`✗ ${p}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`APK: ${libs} библиотек ${ABI}, все выровнены по 16 КБ`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import { checkApk, loadAlignment, REQUIRED } from './check-apk.mjs';
|
||||||
|
|
||||||
|
/** ELF64 с одним PT_LOAD и заданным выравниванием. */
|
||||||
|
function elf(align) {
|
||||||
|
const b = Buffer.alloc(64 + 56);
|
||||||
|
b.writeUInt32BE(0x7f454c46, 0);
|
||||||
|
b[4] = 2;
|
||||||
|
b.writeBigUInt64LE(64n, 0x20);
|
||||||
|
b.writeUInt16LE(56, 0x36);
|
||||||
|
b.writeUInt16LE(1, 0x38);
|
||||||
|
b.writeUInt32LE(1, 64);
|
||||||
|
b.writeBigUInt64LE(BigInt(align), 64 + 0x30);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ZIP без сжатия из пар [имя, содержимое]. */
|
||||||
|
function zip(files) {
|
||||||
|
const locals = [];
|
||||||
|
const central = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (const [name, data] of files) {
|
||||||
|
const n = Buffer.from(name);
|
||||||
|
const lh = Buffer.alloc(30);
|
||||||
|
lh.writeUInt32LE(0x04034b50, 0);
|
||||||
|
lh.writeUInt32LE(data.length, 18);
|
||||||
|
lh.writeUInt32LE(data.length, 22);
|
||||||
|
lh.writeUInt16LE(n.length, 26);
|
||||||
|
locals.push(lh, n, data);
|
||||||
|
const ch = Buffer.alloc(46);
|
||||||
|
ch.writeUInt32LE(0x02014b50, 0);
|
||||||
|
ch.writeUInt32LE(data.length, 20);
|
||||||
|
ch.writeUInt32LE(data.length, 24);
|
||||||
|
ch.writeUInt16LE(n.length, 28);
|
||||||
|
ch.writeUInt32LE(offset, 42);
|
||||||
|
central.push(ch, n);
|
||||||
|
offset += 30 + n.length + data.length;
|
||||||
|
}
|
||||||
|
const cd = Buffer.concat(central);
|
||||||
|
const end = Buffer.alloc(22);
|
||||||
|
end.writeUInt32LE(0x06054b50, 0);
|
||||||
|
end.writeUInt16LE(files.length, 8);
|
||||||
|
end.writeUInt16LE(files.length, 10);
|
||||||
|
end.writeUInt32LE(cd.length, 12);
|
||||||
|
end.writeUInt32LE(offset, 16);
|
||||||
|
return Buffer.concat([...locals, cd, end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('reads the smallest PT_LOAD alignment', () => {
|
||||||
|
assert.equal(loadAlignment(elf(16384)), 16384);
|
||||||
|
assert.equal(loadAlignment(elf(4096)), 4096);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts an arm64-only APK aligned to 16 KB', () => {
|
||||||
|
const apk = zip(REQUIRED.map((l) => [`lib/arm64-v8a/${l}`, elf(16384)]));
|
||||||
|
assert.deepEqual(checkApk(apk).problems, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects other ABIs, missing libraries and 4 KB alignment', () => {
|
||||||
|
const libs = REQUIRED.filter((l) => l !== 'libexpo-modules-core.so').map((l) => [
|
||||||
|
`lib/arm64-v8a/${l}`,
|
||||||
|
elf(l === 'librnskia.so' ? 4096 : 16384),
|
||||||
|
]);
|
||||||
|
const { problems } = checkApk(zip([...libs, ['lib/x86_64/libjsi.so', elf(16384)]]));
|
||||||
|
assert.equal(problems.length, 3);
|
||||||
|
assert.match(problems.join('\n'), /lib\/x86_64/);
|
||||||
|
assert.match(problems.join('\n'), /libexpo-modules-core\.so/);
|
||||||
|
assert.match(problems.join('\n'), /librnskia\.so.*4096/);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configured — адрес и репозиторий заданы, и это не заглушка из .env.example.
|
||||||
func (h *Handler) configured() bool {
|
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) {
|
func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user