feat(admin): transfer user accounts and show local cover coverage
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / db / db (push) Successful in 2m50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / db / db (push) Successful in 2m50s
This commit is contained in:
1 parent
0a4cd4bff1
commit
f1528de948
22 files changed
+1661
-313
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -119,6 +119,9 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
|
||||
httpx.WriteError(log, w, r, apperr.Forbidden(CodePasswordChangeRequired, "change the temporary password first"))
|
||||
return
|
||||
}
|
||||
if route == "POST /api/v1/admin/users/import" {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, MaxUserArchiveBytes)
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(WithPrincipal(ctx, p)))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
const MaxArchivedUsers = 10000
|
||||
const MaxUserArchiveBytes = 16 << 20
|
||||
|
||||
// ArchivedUser is deliberately limited to identity, credentials and access rights.
|
||||
// IDs, sessions and personal music data never cross the transfer boundary.
|
||||
type ArchivedUser struct {
|
||||
Username string
|
||||
DisplayName *string
|
||||
Role Role
|
||||
PasswordHash string
|
||||
IsBlocked bool
|
||||
MustChangePassword bool
|
||||
MustChangeCredentials bool
|
||||
ReviewsUploads bool
|
||||
ManagesUpdates bool
|
||||
}
|
||||
|
||||
func archiveInvalid() error {
|
||||
return apperr.Validation("file", "validation.user_archive_invalid", "invalid user archive", nil)
|
||||
}
|
||||
|
||||
func (s *Service) ExportUsers(ctx context.Context, p Principal) ([]ArchivedUser, error) {
|
||||
if p.Role != RoleAdmin {
|
||||
return nil, forbidden()
|
||||
}
|
||||
out := make([]ArchivedUser, 0)
|
||||
err := s.store.WithTx(ctx, func(tx Store) error {
|
||||
after := ""
|
||||
for {
|
||||
users, err := tx.ListUsers(ctx, UserFilter{After: after, Limit: 200})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(users) == 0 {
|
||||
break
|
||||
}
|
||||
for _, u := range users {
|
||||
if len(out) == MaxArchivedUsers {
|
||||
return archiveInvalid()
|
||||
}
|
||||
out = append(out, ArchivedUser{
|
||||
Username: u.Username, DisplayName: u.DisplayName, Role: u.Role, PasswordHash: u.PasswordHash,
|
||||
IsBlocked: u.IsBlocked, MustChangePassword: u.MustChangePassword,
|
||||
MustChangeCredentials: u.MustChangeCredentials, ReviewsUploads: u.ReviewsUploads, ManagesUpdates: u.ManagesUpdates,
|
||||
})
|
||||
}
|
||||
after = users[len(users)-1].Username
|
||||
}
|
||||
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
||||
ActorID: &p.UserID, Action: "users.export", TargetType: "users", Payload: map[string]any{"count": len(out)},
|
||||
})
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ImportUsers creates all missing accounts in one transaction. Existing accounts
|
||||
// retain their passwords, roles, sessions and personal music data.
|
||||
func (s *Service) ImportUsers(ctx context.Context, p Principal, version int, entries []ArchivedUser) (int, []string, error) {
|
||||
if p.Role != RoleAdmin {
|
||||
return 0, nil, forbidden()
|
||||
}
|
||||
if version != 1 || len(entries) > MaxArchivedUsers {
|
||||
return 0, nil, archiveInvalid()
|
||||
}
|
||||
seen := make(map[string]bool, len(entries))
|
||||
for _, a := range entries {
|
||||
username := NormalizeUsername(a.Username)
|
||||
if username != a.Username || ValidateUsername("username", username) != nil || seen[username] || !a.Role.Valid() {
|
||||
return 0, nil, archiveInvalid()
|
||||
}
|
||||
seen[username] = true
|
||||
if a.DisplayName != nil {
|
||||
if _, err := displayName(*a.DisplayName); err != nil {
|
||||
return 0, nil, archiveInvalid()
|
||||
}
|
||||
}
|
||||
if len(a.PasswordHash) > 512 {
|
||||
return 0, nil, archiveInvalid()
|
||||
}
|
||||
params, salt, key, err := decodePHC(a.PasswordHash)
|
||||
// Imported PHC parameters are used on the next login; bound CPU and RAM
|
||||
// before accepting them, without performing expensive password hashing.
|
||||
if err != nil || params.Threads == 0 || params.Threads > 16 || params.Time == 0 || params.Time > 10 ||
|
||||
params.MemoryKiB < 8*uint32(params.Threads) || params.MemoryKiB > 256*1024 ||
|
||||
len(salt) < 8 || len(salt) > 64 || len(key) < 16 || len(key) > 64 {
|
||||
return 0, nil, archiveInvalid()
|
||||
}
|
||||
}
|
||||
created := 0
|
||||
skipped := make([]string, 0)
|
||||
err := s.store.WithTx(ctx, func(tx Store) error {
|
||||
for _, a := range entries {
|
||||
_, err := tx.GetUserByUsername(ctx, a.Username)
|
||||
if err == nil {
|
||||
skipped = append(skipped, a.Username)
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
locale := "ru"
|
||||
if len(s.cfg.SupportedLocales) > 0 {
|
||||
locale = s.cfg.SupportedLocales[0]
|
||||
}
|
||||
u := User{
|
||||
ID: s.ids.New(), Username: a.Username, DisplayName: a.DisplayName, PasswordHash: a.PasswordHash,
|
||||
Role: a.Role, Locale: locale, CreatedAt: s.clock.Now(), MustChangePassword: a.MustChangePassword,
|
||||
MustChangeCredentials: a.MustChangeCredentials, ReviewsUploads: a.ReviewsUploads, ManagesUpdates: a.ManagesUpdates,
|
||||
}
|
||||
if err := tx.CreateUser(ctx, u, &p.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.IsBlocked {
|
||||
blocked := true
|
||||
if _, err := tx.UpdateUser(ctx, u.ID, UserUpdate{IsBlocked: &blocked}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
created++
|
||||
}
|
||||
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
||||
ActorID: &p.UserID, Action: "users.import", TargetType: "users",
|
||||
Payload: map[string]any{"created": created, "skipped": len(skipped)},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return created, skipped, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
)
|
||||
|
||||
func (h *Handler) ExportUsers(ctx context.Context, _ gen.ExportUsersRequestObject) (gen.ExportUsersResponseObject, error) {
|
||||
p, err := principal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users, err := h.svc.ExportUsers(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
archive := gen.UserArchive{Version: 1, Users: make([]gen.UserArchiveEntry, 0, len(users))}
|
||||
for _, u := range users {
|
||||
archive.Users = append(archive.Users, gen.UserArchiveEntry{
|
||||
Username: u.Username, DisplayName: u.DisplayName, PasswordHash: u.PasswordHash, Role: gen.Role(u.Role),
|
||||
IsBlocked: u.IsBlocked, MustChangePassword: u.MustChangePassword, MustChangeCredentials: u.MustChangeCredentials,
|
||||
ReviewsUploads: u.ReviewsUploads, ManagesUpdates: u.ManagesUpdates,
|
||||
})
|
||||
}
|
||||
return archiveResponse{archive}, nil
|
||||
}
|
||||
|
||||
type archiveResponse struct{ gen.UserArchive }
|
||||
|
||||
func (r archiveResponse) VisitExportUsersResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="pipu-users.json"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
return json.NewEncoder(w).Encode(r.UserArchive)
|
||||
}
|
||||
|
||||
func (h *Handler) ImportUsers(ctx context.Context, req gen.ImportUsersRequestObject) (gen.ImportUsersResponseObject, error) {
|
||||
p, err := principal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Body == nil {
|
||||
return nil, archiveInvalid()
|
||||
}
|
||||
entries := make([]ArchivedUser, 0, len(req.Body.Users))
|
||||
for _, u := range req.Body.Users {
|
||||
entries = append(entries, ArchivedUser{
|
||||
Username: u.Username, DisplayName: u.DisplayName, PasswordHash: u.PasswordHash, Role: Role(u.Role),
|
||||
IsBlocked: u.IsBlocked, MustChangePassword: u.MustChangePassword, MustChangeCredentials: u.MustChangeCredentials,
|
||||
ReviewsUploads: u.ReviewsUploads, ManagesUpdates: u.ManagesUpdates,
|
||||
})
|
||||
}
|
||||
created, skipped, err := h.svc.ImportUsers(ctx, p, int(req.Body.Version), entries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.ImportUsers200JSONResponse{Created: created, Skipped: skipped}, nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/auth"
|
||||
)
|
||||
|
||||
func TestUserArchiveRoundTrip(t *testing.T) {
|
||||
src := newEnv(t)
|
||||
src.addUser("root", "root-original-password", auth.RoleAdmin)
|
||||
src.addUser("listener", "listener-original-password", auth.RoleUser, func(u *auth.User) { u.ReviewsUploads = true })
|
||||
src.addUser("staff", "staff-original-password", auth.RoleModerator, func(u *auth.User) { u.ManagesUpdates = true })
|
||||
src.addUser("blocked", "blocked-original-password", auth.RoleUser, func(u *auth.User) {
|
||||
u.IsBlocked = true
|
||||
u.MustChangePassword = true
|
||||
})
|
||||
token := src.signIn("root", "root-original-password")
|
||||
r := src.do(http.MethodGet, "/api/v1/admin/users/export", nil, bearer(token))
|
||||
require.Equal(t, 200, r.Code, r.Body.String())
|
||||
require.Equal(t, "no-store", r.Header().Get("Cache-Control"))
|
||||
require.Contains(t, r.Header().Get("Content-Disposition"), "attachment")
|
||||
var archive gen.UserArchive
|
||||
require.NoError(t, json.Unmarshal(r.Body.Bytes(), &archive))
|
||||
require.Len(t, archive.Users, 4)
|
||||
for _, field := range []string{"ui_prefs", "refresh", "last_login_at", "recommendations", "history", "likes", "token_version"} {
|
||||
require.NotContains(t, r.Body.String(), `"`+field+`"`)
|
||||
}
|
||||
for _, audit := range src.store.Audit {
|
||||
require.NotContains(t, fmt.Sprint(audit.Payload), "$argon2id$")
|
||||
}
|
||||
|
||||
dst := newEnv(t)
|
||||
root := dst.addUser("root", "destination-root-password", auth.RoleAdmin)
|
||||
destToken := dst.signIn("root", "destination-root-password")
|
||||
result := dst.do(http.MethodPost, "/api/v1/admin/users/import", archive, bearer(destToken))
|
||||
require.Equal(t, 200, result.Code, result.Body.String())
|
||||
require.EqualValues(t, 3, result.body["created"])
|
||||
require.Equal(t, []any{"root"}, result.body["skipped"])
|
||||
for _, username := range []string{"listener", "staff", "blocked"} {
|
||||
original, err := src.store.GetUserByUsername(context.Background(), username)
|
||||
require.NoError(t, err)
|
||||
imported, err := dst.store.GetUserByUsername(context.Background(), username)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, original.ID, imported.ID)
|
||||
require.Equal(t, original.PasswordHash, imported.PasswordHash)
|
||||
require.Equal(t, original.Role, imported.Role)
|
||||
require.Equal(t, original.IsBlocked, imported.IsBlocked)
|
||||
require.Equal(t, original.MustChangePassword, imported.MustChangePassword)
|
||||
require.Equal(t, original.ReviewsUploads, imported.ReviewsUploads)
|
||||
require.Equal(t, original.ManagesUpdates, imported.ManagesUpdates)
|
||||
}
|
||||
retained, err := dst.store.GetUserByUsername(context.Background(), "root")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, root.PasswordHash, retained.PasswordHash)
|
||||
require.Len(t, dst.store.Tokens, 1, "sessions are not imported")
|
||||
require.Empty(t, dst.store.RecResets)
|
||||
dst.mustLogin("listener", "listener-original-password", "web")
|
||||
dst.mustLogin("staff", "staff-original-password", "web")
|
||||
result = dst.do(http.MethodPost, "/api/v1/admin/users/import", archive, bearer(destToken))
|
||||
require.Equal(t, 200, result.Code)
|
||||
require.EqualValues(t, 0, result.body["created"], "import is idempotent")
|
||||
}
|
||||
|
||||
func TestUserArchiveAdminOnly(t *testing.T) {
|
||||
for _, role := range []auth.Role{auth.RoleUser, auth.RoleModerator} {
|
||||
t.Run(string(role), func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("actor", "actor-password-123", role)
|
||||
token := e.signIn("actor", "actor-password-123")
|
||||
for _, endpoint := range []struct {
|
||||
method, path string
|
||||
body any
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/admin/users/export", nil},
|
||||
{http.MethodPost, "/api/v1/admin/users/import", gen.UserArchive{Version: 1, Users: []gen.UserArchiveEntry{}}},
|
||||
} {
|
||||
r := e.do(endpoint.method, endpoint.path, endpoint.body, bearer(token))
|
||||
require.Equal(t, 403, r.Code)
|
||||
require.NotContains(t, r.Body.String(), "$argon2id$")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserArchiveValidationBeforeWrites(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
root := e.addUser("root", "root-password-123", auth.RoleAdmin)
|
||||
hash, err := e.hasher.Hash("valid-password-123")
|
||||
require.NoError(t, err)
|
||||
valid := auth.ArchivedUser{Username: "new-user", Role: auth.RoleUser, PasswordHash: hash}
|
||||
for name, change := range map[string]func(*auth.ArchivedUser){
|
||||
"hash": func(a *auth.ArchivedUser) { a.PasswordHash = "invalid" },
|
||||
"memory": func(a *auth.ArchivedUser) { a.PasswordHash = strings.Replace(hash, "m=64,", "m=4294967295,", 1) },
|
||||
"zero threads": func(a *auth.ArchivedUser) { a.PasswordHash = strings.Replace(hash, "p=1$", "p=0$", 1) },
|
||||
"role": func(a *auth.ArchivedUser) { a.Role = "owner" },
|
||||
"username": func(a *auth.ArchivedUser) { a.Username = "../invalid" },
|
||||
"duplicate": func(a *auth.ArchivedUser) { a.Username = valid.Username },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
bad := valid
|
||||
bad.Username = "other-user"
|
||||
change(&bad)
|
||||
_, _, err := e.svc.ImportUsers(context.Background(), auth.Principal{UserID: root.ID, Role: root.Role}, 1, []auth.ArchivedUser{valid, bad})
|
||||
require.Error(t, err)
|
||||
require.Len(t, e.store.Users, 1, "no partial writes")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type failArchiveAudit struct{ auth.Store }
|
||||
|
||||
func (s failArchiveAudit) WithTx(ctx context.Context, fn func(auth.Store) error) error {
|
||||
return s.Store.WithTx(ctx, func(tx auth.Store) error { return fn(failArchiveAudit{tx}) })
|
||||
}
|
||||
|
||||
func (s failArchiveAudit) InsertAudit(context.Context, uuid.UUID, auth.AuditEntry) error {
|
||||
return errors.New("audit unavailable")
|
||||
}
|
||||
|
||||
func TestUserArchiveRollsBackOnStoreFailure(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
root := e.addUser("root", "root-password-123", auth.RoleAdmin)
|
||||
svc, err := auth.NewService(failArchiveAudit{e.store}, e.hasher, nil, e.limiter, e.clock, auth.UUIDv7{}, nil, auth.DefaultConfig())
|
||||
require.NoError(t, err)
|
||||
_, _, err = svc.ImportUsers(context.Background(), auth.Principal{UserID: root.ID, Role: root.Role}, 1,
|
||||
[]auth.ArchivedUser{{Username: "new-user", Role: auth.RoleUser, PasswordHash: root.PasswordHash}})
|
||||
require.ErrorContains(t, err, "audit unavailable")
|
||||
require.Len(t, e.store.Users, 1)
|
||||
}
|
||||
|
||||
func TestUserArchiveExportsAllPages(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
root := e.addUser("root", "root-password-123", auth.RoleAdmin)
|
||||
for i := range 205 {
|
||||
u := root
|
||||
u.ID, u.Username, u.Role = uuid.New(), fmt.Sprintf("listener-%03d", i), auth.RoleUser
|
||||
require.NoError(t, e.store.CreateUser(context.Background(), u, nil))
|
||||
}
|
||||
users, err := e.svc.ExportUsers(context.Background(), auth.Principal{UserID: root.ID, Role: root.Role})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users, 206)
|
||||
}
|
||||
|
||||
func TestUserArchiveRejectsOversizedRequest(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-123", auth.RoleAdmin)
|
||||
token := e.signIn("root", "root-password-123")
|
||||
r := e.do(http.MethodPost, "/api/v1/admin/users/import", map[string]any{
|
||||
"version": 1, "users": []any{}, "padding": strings.Repeat("x", auth.MaxUserArchiveBytes),
|
||||
}, bearer(token))
|
||||
require.Equal(t, http.StatusBadRequest, r.Code)
|
||||
require.Len(t, e.store.Users, 1)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//go:build integration
|
||||
|
||||
package dbtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"pipu/api/internal/library"
|
||||
"pipu/api/internal/platform/db"
|
||||
)
|
||||
|
||||
func TestSourceCoverStats(t *testing.T) {
|
||||
pool := connect(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, db.Migrate(ctx, pool))
|
||||
store := library.NewStore(pool)
|
||||
src, err := store.SaveSource(ctx, library.Source{ID: uuid.New(), Name: "cover stats", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, NFSOptions: "ro"})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, store.DeleteSource(ctx, src.ID))
|
||||
require.NoError(t, store.CleanupOrphans(ctx))
|
||||
})
|
||||
root := t.TempDir()
|
||||
scanner := library.NewScanner(store, fakeProber{}, library.NewProgressBoard(), slog.New(slog.DiscardHandler))
|
||||
for _, rel := range []string{"covered/StatsArtist - First.flac", "covered/StatsArtist - Second.flac", "external/StatsArtist - Third.flac", "pending/StatsArtist - Fourth.flac"} {
|
||||
file := filepath.Join(root, filepath.FromSlash(rel))
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(file), 0o755))
|
||||
require.NoError(t, os.WriteFile(file, []byte(rel), 0o644))
|
||||
_, err := scanner.ScanFile(ctx, src, root, rel)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `UPDATE albums SET cover_path = 'test-key', cover_checked_at = now() WHERE id IN (
|
||||
SELECT t.album_id FROM tracks t JOIN media_files f ON f.track_id=t.id WHERE f.source_id=$1 AND f.rel_path LIKE 'covered/%')`, src.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = pool.Exec(ctx, `UPDATE albums SET genius_cover_url = 'https://example.org/cover.jpg', cover_checked_at = now() WHERE id IN (
|
||||
SELECT t.album_id FROM tracks t JOIN media_files f ON f.track_id=t.id WHERE f.source_id=$1 AND f.rel_path LIKE 'external/%')`, src.ID)
|
||||
require.NoError(t, err)
|
||||
stats, err := store.SourceCoverStats(ctx, src.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, library.CoverStats{FilesTotal: 4, FilesWithCover: 2, AlbumsPending: 1}, stats)
|
||||
// Removed and excluded files leave the denominator as well as the numerator.
|
||||
_, err = pool.Exec(ctx, `UPDATE media_files SET missing_since=$2 WHERE source_id=$1 AND rel_path LIKE '%Second.flac'`, src.ID, time.Now())
|
||||
require.NoError(t, err)
|
||||
src.ExcludedDirs = []string{"external"}
|
||||
_, err = store.SaveSource(ctx, src)
|
||||
require.NoError(t, err)
|
||||
stats, err = store.SourceCoverStats(ctx, src.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, library.CoverStats{FilesTotal: 2, FilesWithCover: 1, AlbumsPending: 1}, stats)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
)
|
||||
|
||||
type CoverStats struct {
|
||||
FilesTotal int64
|
||||
FilesWithCover int64
|
||||
AlbumsPending int64
|
||||
}
|
||||
|
||||
// SourceCoverStats counts physical audio files, including alternate encodings.
|
||||
// Only completed local covers count; external image URLs are intentionally ignored.
|
||||
func (st *Store) SourceCoverStats(ctx context.Context, id uuid.UUID) (CoverStats, error) {
|
||||
var stats CoverStats
|
||||
err := st.pool.QueryRow(ctx, `
|
||||
SELECT count(*), count(*) FILTER (WHERE al.cover_path IS NOT NULL),
|
||||
count(DISTINCT al.id) FILTER (WHERE al.cover_checked_at IS NULL)
|
||||
FROM media_files mf
|
||||
JOIN library_sources s ON s.id = mf.source_id
|
||||
LEFT JOIN tracks t ON t.id = mf.track_id
|
||||
LEFT JOIN albums al ON al.id = t.album_id
|
||||
WHERE mf.source_id = $1 AND mf.missing_since IS NULL AND mf.scan_error IS NULL
|
||||
AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs)`, id).
|
||||
Scan(&stats.FilesTotal, &stats.FilesWithCover, &stats.AlbumsPending)
|
||||
if err != nil {
|
||||
return CoverStats{}, fmt.Errorf("library: source cover stats: %w", err)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (h *Handler) GetSourceCoverStats(ctx context.Context, req gen.GetSourceCoverStatsRequestObject) (gen.GetSourceCoverStatsResponseObject, error) {
|
||||
if _, err := h.svc.GetSource(ctx, req.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats, err := h.store.SourceCoverStats(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.GetSourceCoverStats200JSONResponse{
|
||||
FilesTotal: stats.FilesTotal, FilesWithCover: stats.FilesWithCover, AlbumsPending: stats.AlbumsPending,
|
||||
}, nil
|
||||
}
|
||||
Reference in new issue
Block a user