feat(database): shape the library like files, and shrink the catalog
Plans 013 and 014, the album page that prompted them, and the smaller fixes they turned up. Changelog, largest first. ## The local library is shaped like files, not like MusicBrainz `audio_files` carries its own tags and points at `albums` and `artists`; `file_genres` is the one real many-to-many. `recordings`, `release_group_recordings`, `artist_credit`, `artist_credit_artist`, `recording_genres`, `release_groups` and `release_to_rg` are gone from the local side, and with them a six-way join in every read, a `MIN(release_group_id)` subquery in eleven queries and a first-credited-artist subquery in nine. Measured on a real 25,966-file library, every many-to-many that model expressed was 1:1 in the data. - Ownership is a file. `GetFilePathsByRecordingMBIDs`, `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812 orphaned recordings, 216 release groups and 260 artists that library carried are now structurally impossible. - One projection: every track query selects from the `track_metadata` view, one row type, one mapper. Nine hand-rolled copies had drifted far enough to report different years on different screens. - `library_id = 0` means every library, so each list query exists once instead of scoped and unscoped with a branch at every call site. - No migration chain. `sql/schemas/` is the one description of the shape; `sql/migrations/`, `applyMigrations` and `schema_migrations` are squashed away, along with the drift between them that had sqlc generating against a stale schema. - `database.InsertTestTrack` is the one test seeder; twenty test files had been assembling the old FK chain each in its own order. ## The catalog stores its ids as bytes `explore_index`'s three 36-char MBID columns and its entity-type text are 16 raw bytes and a small integer. The table and its six indexes go 780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh install is ~0.6 GB rather than ~1.0 GB. - `backend/explore/mbid.go` is the only place the encoding is known; everything above it speaks dashed strings. - `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert rather than silently returning no rows, since SQLite does not coerce between TEXT and BLOB. - The importer asks the artifact what encoding it carries and converts on the way in, so the artifact already published keeps working and no format bump is needed. - `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column list, and `TestStoredEncodingRoundTrips` sweeps every read path. ## An album page that says how much of the album is yours - One question, asked once: is there a file. `filePaths` is filled by a single batched lookup when the tracklist settles, and the badge, the Play count, the dimmed rows and every menu item read it — replacing four claims of decreasing confidence that could show a green tick on an album whose every action did nothing. - Play, Play 7 of 12, or no play button at all. - `total_tracks` on `explore_index` (~2 bytes over 400,677 release groups) and on `audio_files` from tags that have always carried it: a complete MBID-matched album now makes no catalog call at all, where it used to spend the most expensive request the app makes. - A merged cluster shows the running order the most releases agree on, and the version list marks the release you own rather than standing a synthetic entry in for it. - `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed one by a 12-second timer. - Rows not in the library are dimmed in place (with `aria-disabled`) instead of the owned ones wearing a green tick and a legend. ## Caches and cover art get ceilings - Only the three tiers of a cover are stored; the full-resolution copy nothing rendered was 1,134 MB of a 1.4 GB covers directory. - One artist portrait is downloaded and the rest are remembered as URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads. - `browsedArtBudget` and `httpCacheBudget` bound what an age cannot: the same install held art for 5,770 artists in a 1,301-artist library. - `OrphanedArtistImagesJob` joined a bare MBID onto a sharded directory, so it deleted the rows that were the only record of the files it left behind. `explore.ArtistImageDir` is that layout's one definition now. ## The autotag queue asks whether there is work `tagging_items` was a row per album folder, not a queue, and no query read the `tag_status` column that held the answer. The four queue queries ask the files, which matters most where it is least visible: `startPrefetch` was scoring every album in a tagged library against MusicBrainz. ## Phantom playlist tracks resolve in place An M3U8 imported before its files leaves phantom rows; they now match by path and fall back to position, keep their place in the playlist when resolved, and pair best-first so two phantoms cannot claim the same file. ## Playing a track plays the list it is in Double-click, and Play on a single row's menu, queue the list as displayed with `startIndex` on that row — the album page and the track list used to queue one track and discard the album around it. A multi-row selection still plays exactly itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
This commit is contained in:
+130
-329
@@ -22,21 +22,26 @@ import (
|
||||
"yellowjacket/backend/metadata"
|
||||
)
|
||||
|
||||
// dbSyncParams holds the context needed by syncDatabase to update
|
||||
// all database entities after a successful file tag write.
|
||||
// dbSyncParams holds the context needed by syncDatabase to update the
|
||||
// database after a successful file tag write.
|
||||
type dbSyncParams struct {
|
||||
audioFileID int64
|
||||
recordingID int64
|
||||
filePath string
|
||||
changes TagChanges
|
||||
oldRecording sqlcgen.Recording
|
||||
oldRGLinks []sqlcgen.ReleaseGroupRecording
|
||||
audioFileID int64
|
||||
filePath string
|
||||
changes TagChanges
|
||||
oldFile sqlcgen.AudioFile
|
||||
}
|
||||
|
||||
// syncDatabase runs all database updates for a tag write inside a
|
||||
// single transaction: entity upsert-and-relink, FTS5 update, and
|
||||
// orphan cleanup. If anything fails the entire transaction is
|
||||
// rolled back, leaving the database at its previous state.
|
||||
// syncDatabase runs the database updates for a tag write inside a
|
||||
// single transaction: the file's own tag columns, its album and artist
|
||||
// links, its genres, its cover art, and the FTS row. If anything fails
|
||||
// the whole transaction rolls back.
|
||||
//
|
||||
// This used to be four times longer, and three of the four parts were
|
||||
// bookkeeping for tables that no longer exist: relinking a recording to
|
||||
// a new artist_credit, unlinking and relinking release_group_recordings,
|
||||
// and then three orphan sweeps to delete whichever of those rows the
|
||||
// relink had stranded. Tags live on the file now, so changing a tag is
|
||||
// an UPDATE, and nothing can be stranded by one.
|
||||
func syncDatabase(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
@@ -51,288 +56,182 @@ func syncDatabase(
|
||||
defer func() { _ = tx.Rollback() }() // no-op after commit
|
||||
|
||||
txq := db.Queries.WithTx(tx)
|
||||
old := params.oldFile
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Track old entity IDs for orphan cleanup after relinking.
|
||||
// 1. Resolve the artist, if it changed.
|
||||
// ------------------------------------------------------------------
|
||||
oldArtistCreditID := params.oldRecording.ArtistCreditID
|
||||
artistCredit := old.ArtistCredit
|
||||
artistID := old.ArtistID
|
||||
|
||||
oldRGIDs := make([]int64, 0, len(params.oldRGLinks))
|
||||
for _, link := range params.oldRGLinks {
|
||||
oldRGIDs = append(oldRGIDs, link.ReleaseGroupID)
|
||||
}
|
||||
|
||||
// newArtistCreditID starts as old; overwritten if artist changes.
|
||||
newArtistCreditID := oldArtistCreditID
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 1. Handle artist change.
|
||||
// ------------------------------------------------------------------
|
||||
if v, ok := params.changes[FieldArtist].(string); ok {
|
||||
newAC, acErr := txq.UpsertArtistCredit(ctx, v)
|
||||
if acErr != nil {
|
||||
return fmt.Errorf("upsert artist credit: %w", acErr)
|
||||
}
|
||||
artistCredit = v
|
||||
|
||||
newArtistCreditID = newAC.ID
|
||||
|
||||
newArtist, artErr := txq.UpsertArtist(ctx, v)
|
||||
artist, artErr := txq.UpsertArtist(ctx, sqlcgen.UpsertArtistParams{Name: v})
|
||||
if artErr != nil {
|
||||
return fmt.Errorf("upsert artist: %w", artErr)
|
||||
}
|
||||
|
||||
// Link artist → credit (INSERT OR IGNORE handles dupes).
|
||||
if _, linkErr := txq.CreateArtistCreditArtist(ctx,
|
||||
sqlcgen.CreateArtistCreditArtistParams{
|
||||
ArtistID: newArtist.ID,
|
||||
CreditID: newAC.ID,
|
||||
},
|
||||
); linkErr != nil && !database.IsUniqueViolation(linkErr) {
|
||||
return fmt.Errorf("link artist to credit: %w", linkErr)
|
||||
}
|
||||
artistID = sql.NullInt64{Int64: artist.ID, Valid: true}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 2. Handle album change.
|
||||
// 2. Resolve the album, if it changed.
|
||||
// ------------------------------------------------------------------
|
||||
albumID := old.AlbumID
|
||||
|
||||
if newAlbumName, ok := params.changes[FieldAlbum].(string); ok {
|
||||
// Determine album-artist credit ID.
|
||||
albumArtistCreditID := sql.NullInt64{
|
||||
Int64: newArtistCreditID, Valid: true,
|
||||
}
|
||||
albumCredit := artistCredit
|
||||
albumArtistID := artistID
|
||||
|
||||
if aav, aaOK := params.changes[FieldAlbumArtist].(string); aaOK && aav != "" {
|
||||
aaCredit, aaErr := txq.UpsertArtistCredit(ctx, aav)
|
||||
albumCredit = aav
|
||||
|
||||
aaArtist, aaErr := txq.UpsertArtist(ctx, sqlcgen.UpsertArtistParams{Name: aav})
|
||||
if aaErr != nil {
|
||||
return fmt.Errorf("upsert album artist credit: %w", aaErr)
|
||||
return fmt.Errorf("upsert album artist: %w", aaErr)
|
||||
}
|
||||
|
||||
albumArtistCreditID = sql.NullInt64{
|
||||
Int64: aaCredit.ID, Valid: true,
|
||||
}
|
||||
|
||||
aaArtist, aaArtErr := txq.UpsertArtist(ctx, aav)
|
||||
if aaArtErr != nil {
|
||||
return fmt.Errorf("upsert album artist: %w", aaArtErr)
|
||||
}
|
||||
|
||||
if _, aaLinkErr := txq.CreateArtistCreditArtist(ctx,
|
||||
sqlcgen.CreateArtistCreditArtistParams{
|
||||
ArtistID: aaArtist.ID,
|
||||
CreditID: aaCredit.ID,
|
||||
},
|
||||
); aaLinkErr != nil && !database.IsUniqueViolation(aaLinkErr) {
|
||||
return fmt.Errorf("link album artist to credit: %w", aaLinkErr)
|
||||
}
|
||||
albumArtistID = sql.NullInt64{Int64: aaArtist.ID, Valid: true}
|
||||
}
|
||||
|
||||
// Determine year value.
|
||||
yearVal := params.oldRecording.Year
|
||||
year := old.Year
|
||||
if yv, yOK := asInt(params.changes[FieldYear]); yOK {
|
||||
yearVal = toNullInt64(yv)
|
||||
year = toNullInt64(yv)
|
||||
}
|
||||
|
||||
// Upsert new release group.
|
||||
newRG, rgErr := txq.UpsertReleaseGroup(ctx,
|
||||
sqlcgen.UpsertReleaseGroupParams{
|
||||
Name: newAlbumName,
|
||||
AlbumArtistCreditID: albumArtistCreditID,
|
||||
Year: yearVal,
|
||||
},
|
||||
)
|
||||
if rgErr != nil {
|
||||
return fmt.Errorf("upsert release group: %w", rgErr)
|
||||
album, albErr := txq.UpsertAlbum(ctx, sqlcgen.UpsertAlbumParams{
|
||||
Name: newAlbumName,
|
||||
ArtistCredit: albumCredit,
|
||||
ArtistID: albumArtistID,
|
||||
Year: year,
|
||||
})
|
||||
if albErr != nil {
|
||||
return fmt.Errorf("upsert album: %w", albErr)
|
||||
}
|
||||
|
||||
// Unlink old release_group_recordings.
|
||||
for _, oldLink := range params.oldRGLinks {
|
||||
if unlinkErr := txq.DeleteReleaseGroupRecordingByFK(ctx,
|
||||
sqlcgen.DeleteReleaseGroupRecordingByFKParams{
|
||||
ReleaseGroupID: oldLink.ReleaseGroupID,
|
||||
RecordingID: params.recordingID,
|
||||
},
|
||||
); unlinkErr != nil {
|
||||
return fmt.Errorf("unlink old rg recording: %w", unlinkErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine track/disc numbers.
|
||||
trackNum := params.oldRecording.TrackNumber
|
||||
if tn, tnOK := asInt(params.changes[FieldTrackNumber]); tnOK {
|
||||
trackNum = toNullInt64(tn)
|
||||
}
|
||||
|
||||
discNum := params.oldRecording.DiscNumber
|
||||
if dn, dnOK := asInt(params.changes[FieldDiscNumber]); dnOK {
|
||||
discNum = toNullInt64(dn)
|
||||
}
|
||||
|
||||
// Create new link.
|
||||
if _, linkErr := txq.CreateReleaseGroupRecording(ctx,
|
||||
sqlcgen.CreateReleaseGroupRecordingParams{
|
||||
ReleaseGroupID: newRG.ID,
|
||||
RecordingID: params.recordingID,
|
||||
TrackNumber: trackNum,
|
||||
DiscNumber: discNum,
|
||||
},
|
||||
); linkErr != nil {
|
||||
return fmt.Errorf("create rg recording link: %w", linkErr)
|
||||
}
|
||||
albumID = sql.NullInt64{Int64: album.ID, Valid: true}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. Handle genre change.
|
||||
// 3. Genres, if they changed.
|
||||
// ------------------------------------------------------------------
|
||||
if newGenre, ok := params.changes[FieldGenre].(string); ok {
|
||||
// Delete all existing recording_genres for this recording.
|
||||
if delErr := txq.DeleteRecordingGenres(ctx, params.recordingID); delErr != nil {
|
||||
return fmt.Errorf("delete recording genres: %w", delErr)
|
||||
if delErr := txq.DeleteFileGenres(ctx, params.audioFileID); delErr != nil {
|
||||
return fmt.Errorf("delete file genres: %w", delErr)
|
||||
}
|
||||
|
||||
// Parse and link new genres.
|
||||
genres := metadata.ParseGenres(newGenre)
|
||||
for _, gName := range genres {
|
||||
for _, gName := range metadata.ParseGenres(newGenre) {
|
||||
g, gErr := txq.UpsertGenre(ctx, gName)
|
||||
if gErr != nil {
|
||||
return fmt.Errorf("upsert genre %q: %w", gName, gErr)
|
||||
}
|
||||
|
||||
if rgErr := txq.CreateRecordingGenre(ctx,
|
||||
sqlcgen.CreateRecordingGenreParams{
|
||||
RecordingID: params.recordingID,
|
||||
GenreID: g.ID,
|
||||
},
|
||||
); rgErr != nil {
|
||||
return fmt.Errorf("create recording genre: %w", rgErr)
|
||||
if linkErr := txq.LinkFileGenre(ctx, sqlcgen.LinkFileGenreParams{
|
||||
AudioFileID: params.audioFileID,
|
||||
GenreID: g.ID,
|
||||
}); linkErr != nil {
|
||||
return fmt.Errorf("link file genre: %w", linkErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4. Handle cover art change — save image to covers cache,
|
||||
// upsert cover_art row, and update release_groups.cover_art_id.
|
||||
// 4. Cover art, if it changed. It belongs to the album, so a file
|
||||
// with no album has nowhere to put it.
|
||||
// ------------------------------------------------------------------
|
||||
if _, hasCoverArt := params.changes[FieldCoverArt]; hasCoverArt {
|
||||
coverArtData, isBytes := asBytes(params.changes[FieldCoverArt])
|
||||
if _, hasCoverArt := params.changes[FieldCoverArt]; hasCoverArt && albumID.Valid {
|
||||
coverArtID := sql.NullInt64{}
|
||||
|
||||
if isBytes && len(coverArtData) > 0 {
|
||||
// Save to covers dir and upsert DB row.
|
||||
newCoverArtID, caErr := saveCoverArtAndSync(
|
||||
ctx, logger, txq, coverArtData,
|
||||
)
|
||||
if data, isBytes := asBytes(params.changes[FieldCoverArt]); isBytes && len(data) > 0 {
|
||||
newID, caErr := saveCoverArtAndSync(ctx, logger, txq, data)
|
||||
if caErr != nil {
|
||||
logger.Warn("cover art sync failed", "err", caErr)
|
||||
} else {
|
||||
// Update all release groups linked to this recording.
|
||||
for _, rgLink := range params.oldRGLinks {
|
||||
if upErr := txq.UpdateReleaseGroupCoverArt(ctx,
|
||||
sqlcgen.UpdateReleaseGroupCoverArtParams{
|
||||
CoverArtID: sql.NullInt64{Int64: newCoverArtID, Valid: true},
|
||||
ID: rgLink.ReleaseGroupID,
|
||||
},
|
||||
); upErr != nil {
|
||||
logger.Warn("update rg cover art failed",
|
||||
"err", upErr,
|
||||
"releaseGroupID", rgLink.ReleaseGroupID)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clear: set cover_art_id to NULL on all linked release groups.
|
||||
for _, rgLink := range params.oldRGLinks {
|
||||
if upErr := txq.UpdateReleaseGroupCoverArt(ctx,
|
||||
sqlcgen.UpdateReleaseGroupCoverArtParams{
|
||||
CoverArtID: sql.NullInt64{},
|
||||
ID: rgLink.ReleaseGroupID,
|
||||
},
|
||||
); upErr != nil {
|
||||
logger.Warn("clear rg cover art failed",
|
||||
"err", upErr,
|
||||
"releaseGroupID", rgLink.ReleaseGroupID)
|
||||
}
|
||||
coverArtID = sql.NullInt64{Int64: newID, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
if upErr := txq.SetAlbumCoverArt(ctx, sqlcgen.SetAlbumCoverArtParams{
|
||||
CoverArtID: coverArtID,
|
||||
ID: albumID.Int64,
|
||||
}); upErr != nil {
|
||||
logger.Warn("update album cover art failed", "err", upErr, "albumID", albumID.Int64)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 5. Update recording with all changed fields.
|
||||
// 5. The file's own tag columns.
|
||||
// ------------------------------------------------------------------
|
||||
rec := params.oldRecording
|
||||
|
||||
newName := rec.Name
|
||||
title := old.Title
|
||||
if v, ok := params.changes[FieldTitle].(string); ok {
|
||||
newName = v
|
||||
title = v
|
||||
}
|
||||
|
||||
newYear := rec.Year
|
||||
year := old.Year
|
||||
if v, ok := asInt(params.changes[FieldYear]); ok {
|
||||
newYear = toNullInt64(v)
|
||||
year = toNullInt64(v)
|
||||
}
|
||||
|
||||
newTrackNum := rec.TrackNumber
|
||||
trackNum := old.TrackNumber
|
||||
if v, ok := asInt(params.changes[FieldTrackNumber]); ok {
|
||||
newTrackNum = toNullInt64(v)
|
||||
trackNum = toNullInt64(v)
|
||||
}
|
||||
|
||||
newDiscNum := rec.DiscNumber
|
||||
discNum := old.DiscNumber
|
||||
if v, ok := asInt(params.changes[FieldDiscNumber]); ok {
|
||||
newDiscNum = toNullInt64(v)
|
||||
discNum = toNullInt64(v)
|
||||
}
|
||||
|
||||
newGenreStr := rec.Genre
|
||||
if v, ok := params.changes[FieldGenre].(string); ok {
|
||||
newGenreStr = toNullString(v)
|
||||
}
|
||||
|
||||
newComposer := rec.Composer
|
||||
composer := old.Composer
|
||||
if v, ok := params.changes[FieldComposer].(string); ok {
|
||||
newComposer = toNullString(v)
|
||||
composer = v
|
||||
}
|
||||
|
||||
if updErr := txq.UpdateRecordingFull(ctx, sqlcgen.UpdateRecordingFullParams{
|
||||
Name: newName,
|
||||
ArtistCreditID: newArtistCreditID,
|
||||
TrackNumber: newTrackNum,
|
||||
DiscNumber: newDiscNum,
|
||||
Year: newYear,
|
||||
Genre: newGenreStr,
|
||||
Composer: newComposer,
|
||||
Lyrics: rec.Lyrics,
|
||||
Comment: rec.Comment,
|
||||
ID: params.recordingID,
|
||||
// Writing tags rewrites the file, changing its mtime and possibly
|
||||
// its size. Recording the new values keeps the scan from mistaking
|
||||
// YellowJacket's own edit for an external one and re-importing.
|
||||
modifiedAt, fileSize := old.ModifiedAt, old.FileSize
|
||||
|
||||
if info, statErr := os.Stat(params.filePath); statErr != nil {
|
||||
logger.Warn("could not stat file after tag write",
|
||||
"path", params.filePath, "err", statErr)
|
||||
} else {
|
||||
modifiedAt, fileSize = info.ModTime().Unix(), info.Size()
|
||||
}
|
||||
|
||||
if updErr := txq.UpdateAudioFileTags(ctx, sqlcgen.UpdateAudioFileTagsParams{
|
||||
Title: title,
|
||||
ArtistCredit: artistCredit,
|
||||
ArtistID: artistID,
|
||||
AlbumID: albumID,
|
||||
TrackNumber: trackNum,
|
||||
DiscNumber: discNum,
|
||||
TotalTracks: old.TotalTracks,
|
||||
Year: year,
|
||||
Composer: composer,
|
||||
Comment: old.Comment,
|
||||
RecordingMbid: old.RecordingMbid,
|
||||
SampleRate: old.SampleRate,
|
||||
BitDepth: old.BitDepth,
|
||||
Channels: old.Channels,
|
||||
Bitrate: old.Bitrate,
|
||||
FileSize: fileSize,
|
||||
LengthMilliseconds: old.LengthMilliseconds,
|
||||
ModifiedAt: modifiedAt,
|
||||
ID: params.audioFileID,
|
||||
}); updErr != nil {
|
||||
return fmt.Errorf("update recording: %w", updErr)
|
||||
return fmt.Errorf("update audio file tags: %w", updErr)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 6. Update FTS5 search index (within the transaction).
|
||||
// 6. The FTS row.
|
||||
// ------------------------------------------------------------------
|
||||
newTitle := newName
|
||||
album := ""
|
||||
|
||||
newArtist := params.changes[FieldArtist]
|
||||
artistStr := ""
|
||||
|
||||
if newArtist != nil {
|
||||
artistStr, _ = newArtist.(string)
|
||||
}
|
||||
|
||||
if artistStr == "" {
|
||||
// Look up current artist credit text from the old recording
|
||||
// if the artist hasn't changed.
|
||||
ac, acErr := txq.GetArtistCredit(ctx, newArtistCreditID)
|
||||
if acErr == nil {
|
||||
artistStr = ac.Text
|
||||
}
|
||||
}
|
||||
|
||||
newAlbum := ""
|
||||
if v, ok := params.changes[FieldAlbum].(string); ok {
|
||||
newAlbum = v
|
||||
} else if len(params.oldRGLinks) > 0 {
|
||||
// Look up current album from release groups if unchanged.
|
||||
rg, rgErr := txq.GetReleaseGroup(ctx, params.oldRGLinks[0].ReleaseGroupID)
|
||||
if rgErr == nil {
|
||||
newAlbum = rg.Name
|
||||
if albumID.Valid {
|
||||
if row, albErr := txq.GetAlbum(ctx, albumID.Int64); albErr == nil {
|
||||
album = row.Name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,97 +248,14 @@ func syncDatabase(
|
||||
if _, ftsInsErr := tx.ExecContext(ctx,
|
||||
`INSERT INTO search_index(rowid, file_path, title, artist, album)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
params.audioFileID, params.filePath, newTitle, artistStr, newAlbum,
|
||||
params.audioFileID, params.filePath, title, artistCredit, album,
|
||||
); ftsInsErr != nil {
|
||||
logger.Warn("FTS5 insert failed", "err", ftsInsErr,
|
||||
"audioFileID", params.audioFileID)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 7. Orphan cleanup (within same transaction).
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// 7a. Artist credit orphan cleanup.
|
||||
if newArtistCreditID != oldArtistCreditID {
|
||||
refCount, refErr := txq.CountArtistCreditReferences(ctx, oldArtistCreditID)
|
||||
if refErr != nil {
|
||||
logger.Warn("count artist credit refs failed", "err", refErr)
|
||||
} else if refCount == 0 {
|
||||
// Delete artist_credit_artist entries for the orphaned credit,
|
||||
// then the credit itself.
|
||||
// SAFETY: Hand-crafted DELETE for orphan artist_credit_artist rows.
|
||||
// Parameterized credit_id. No sqlc query exists for this specific
|
||||
// delete-by-credit pattern.
|
||||
if _, acaErr := tx.ExecContext(ctx,
|
||||
"DELETE FROM artist_credit_artist WHERE credit_id = ?",
|
||||
oldArtistCreditID,
|
||||
); acaErr != nil {
|
||||
logger.Warn("delete orphan aca failed", "err", acaErr)
|
||||
}
|
||||
|
||||
if delErr := txq.DeleteArtistCredit(ctx, oldArtistCreditID); delErr != nil {
|
||||
logger.Warn("delete orphan artist credit failed", "err", delErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7b. Release group orphan cleanup.
|
||||
if _, albumChanged := params.changes[FieldAlbum]; albumChanged {
|
||||
for _, oldRGID := range oldRGIDs {
|
||||
rgCount, rgErr := txq.CountReleaseGroupRecordings(ctx, oldRGID)
|
||||
if rgErr != nil {
|
||||
logger.Warn("count rg recordings failed", "err", rgErr,
|
||||
"releaseGroupID", oldRGID)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if rgCount == 0 {
|
||||
if delErr := txq.DeleteReleaseGroup(ctx, oldRGID); delErr != nil {
|
||||
logger.Warn("delete orphan release group failed",
|
||||
"err", delErr, "releaseGroupID", oldRGID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7c. Genre orphan cleanup — delete genres with no remaining
|
||||
// recording_genres references. This is safe because genres
|
||||
// are only referenced via recording_genres.
|
||||
if _, genreChanged := params.changes[FieldGenre]; genreChanged {
|
||||
// SAFETY: Hand-crafted DELETE for orphan genres. No user input.
|
||||
// Matches the global orphan pattern from library/crud.go.
|
||||
if _, gErr := tx.ExecContext(ctx,
|
||||
`DELETE FROM genres WHERE id NOT IN
|
||||
(SELECT DISTINCT genre_id FROM recording_genres)`,
|
||||
); gErr != nil {
|
||||
logger.Warn("genre orphan cleanup failed", "err", gErr)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 7d. Re-baseline the staleness fields. Writing tags rewrites the
|
||||
// file, changing its mtime and possibly its size. Recording the
|
||||
// new values here keeps the scan from mistaking YellowJacket's
|
||||
// own edit for an external one and re-importing the track.
|
||||
// ------------------------------------------------------------------
|
||||
if info, statErr := os.Stat(params.filePath); statErr != nil {
|
||||
logger.Warn("could not stat file after tag write",
|
||||
"path", params.filePath, "err", statErr)
|
||||
} else if updErr := txq.UpdateAudioFileStat(ctx,
|
||||
sqlcgen.UpdateAudioFileStatParams{
|
||||
ModifiedAt: info.ModTime().Unix(),
|
||||
FileSize: info.Size(),
|
||||
ID: params.audioFileID,
|
||||
},
|
||||
); updErr != nil {
|
||||
logger.Warn("could not update file stat after tag write",
|
||||
"path", params.filePath, "err", updErr)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 8. Commit.
|
||||
// ------------------------------------------------------------------
|
||||
// Albums and artists left empty by this write are swept by the
|
||||
// library's own cleanup, which is one query each now.
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -452,15 +268,6 @@ func toNullInt64(v int) sql.NullInt64 {
|
||||
return sql.NullInt64{Int64: int64(v), Valid: true}
|
||||
}
|
||||
|
||||
// toNullString converts a string to sql.NullString, treating empty as null.
|
||||
func toNullString(v string) sql.NullString {
|
||||
if v == "" {
|
||||
return sql.NullString{}
|
||||
}
|
||||
|
||||
return sql.NullString{String: v, Valid: true}
|
||||
}
|
||||
|
||||
// saveCoverArtAndSync saves cover art bytes to the covers cache
|
||||
// directory (with content-hash deduplication), generates sized
|
||||
// thumbnails, upserts a cover_art DB row, and returns the row ID.
|
||||
@@ -479,27 +286,18 @@ func saveCoverArtAndSync(
|
||||
return 0, fmt.Errorf("create covers dir: %w", err)
|
||||
}
|
||||
|
||||
// Content-hash filename (same scheme as library/coverart.go).
|
||||
// Content-hash filename (same scheme as library/coverart.go): the
|
||||
// tiers are the only thing stored, and the largest is the cover's
|
||||
// canonical path. The full-resolution bytes stay in the file the
|
||||
// user just wrote them to.
|
||||
hash := sha256.Sum256(data)
|
||||
hashStr := hex.EncodeToString(hash[:8])
|
||||
mime := detectMIME(data)
|
||||
|
||||
ext := "jpg"
|
||||
if mime == "image/png" {
|
||||
ext = "png"
|
||||
}
|
||||
filePath := filepath.Join(
|
||||
coverDir, coverart.SizedFilename(hashStr, largestTierSuffix),
|
||||
)
|
||||
|
||||
filename := fmt.Sprintf("%s.%s", hashStr, ext)
|
||||
filePath := filepath.Join(coverDir, filename)
|
||||
|
||||
// Write original if not already present.
|
||||
if _, statErr := os.Stat(filePath); statErr != nil {
|
||||
if writeErr := os.WriteFile(filePath, data, 0o644); writeErr != nil {
|
||||
return 0, fmt.Errorf("write cover art: %w", writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate sized variants (thumbnails).
|
||||
generateSizedVariants(logger, data, coverDir, hashStr)
|
||||
|
||||
// Upsert cover_art DB row.
|
||||
@@ -522,6 +320,9 @@ type thumbnailTier struct {
|
||||
quality int
|
||||
}
|
||||
|
||||
// largestTierSuffix is the tier stored as a cover's canonical path.
|
||||
const largestTierSuffix = "_lg"
|
||||
|
||||
// thumbnailTiers matches the tiers in library/coverart.go.
|
||||
var thumbnailTiers = []thumbnailTier{
|
||||
{suffix: "_sm", maxSize: 100, quality: 75},
|
||||
|
||||
@@ -118,16 +118,6 @@ func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error {
|
||||
return fmt.Errorf("get audio file %d: %w", trackID, err)
|
||||
}
|
||||
|
||||
recording, err := tw.db.Queries.GetRecording(ctx, audioFile.RecordingID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get recording %d: %w", audioFile.RecordingID, err)
|
||||
}
|
||||
|
||||
rgLinks, err := tw.db.Queries.GetRecordingReleaseGroups(ctx, recording.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get recording rg links: %w", err)
|
||||
}
|
||||
|
||||
// 2. Detect format.
|
||||
format, err := DetectFormat(audioFile.FilePath)
|
||||
if err != nil {
|
||||
@@ -165,12 +155,10 @@ func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error {
|
||||
|
||||
// 6. Sync database.
|
||||
if syncErr := syncDatabase(ctx, tw.logger, tw.db, dbSyncParams{
|
||||
audioFileID: audioFile.ID,
|
||||
recordingID: recording.ID,
|
||||
filePath: audioFile.FilePath,
|
||||
changes: changes,
|
||||
oldRecording: recording,
|
||||
oldRGLinks: rgLinks,
|
||||
audioFileID: audioFile.ID,
|
||||
filePath: audioFile.FilePath,
|
||||
changes: changes,
|
||||
oldFile: audioFile,
|
||||
}); syncErr != nil {
|
||||
tw.logger.Error("db sync failed after successful file write",
|
||||
"err", syncErr,
|
||||
|
||||
@@ -2,12 +2,9 @@ package tagwriter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
)
|
||||
|
||||
// --- Mock implementations ---
|
||||
@@ -58,110 +55,17 @@ func seedTestTrack(
|
||||
) int64 {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
q := db.Queries
|
||||
|
||||
// Artist credit.
|
||||
ac, err := q.UpsertArtistCredit(ctx, "Old Artist")
|
||||
if err != nil {
|
||||
t.Fatalf("upsert artist credit: %v", err)
|
||||
}
|
||||
|
||||
artist, err := q.UpsertArtist(ctx, "Old Artist")
|
||||
if err != nil {
|
||||
t.Fatalf("upsert artist: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateArtistCreditArtist(ctx,
|
||||
sqlcgen.CreateArtistCreditArtistParams{
|
||||
ArtistID: artist.ID,
|
||||
CreditID: ac.ID,
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("create aca: %v", err)
|
||||
}
|
||||
|
||||
// Recording.
|
||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
||||
Name: "Old Title",
|
||||
ArtistCreditID: ac.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: 1, Valid: true},
|
||||
DiscNumber: sql.NullInt64{Int64: 1, Valid: true},
|
||||
Year: sql.NullInt64{Int64: 2020, Valid: true},
|
||||
Genre: sql.NullString{String: "Rock", Valid: true},
|
||||
Composer: sql.NullString{String: "Old Composer", Valid: true},
|
||||
return database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: filePath,
|
||||
Title: "Old Title",
|
||||
Artist: "Old Artist",
|
||||
Album: "Old Album",
|
||||
Genres: []string{"Rock"},
|
||||
TrackNumber: 1,
|
||||
DiscNumber: 1,
|
||||
Year: 2020,
|
||||
LengthMs: 180000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create recording: %v", err)
|
||||
}
|
||||
|
||||
// Release group.
|
||||
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
|
||||
Name: "Old Album",
|
||||
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
|
||||
Year: sql.NullInt64{Int64: 2020, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert release group: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateReleaseGroupRecording(ctx,
|
||||
sqlcgen.CreateReleaseGroupRecordingParams{
|
||||
ReleaseGroupID: rg.ID,
|
||||
RecordingID: rec.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: 1, Valid: true},
|
||||
DiscNumber: sql.NullInt64{Int64: 1, Valid: true},
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("create rg recording: %v", err)
|
||||
}
|
||||
|
||||
// Genre.
|
||||
genre, err := q.UpsertGenre(ctx, "Rock")
|
||||
if err != nil {
|
||||
t.Fatalf("upsert genre: %v", err)
|
||||
}
|
||||
|
||||
if err := q.CreateRecordingGenre(ctx, sqlcgen.CreateRecordingGenreParams{
|
||||
RecordingID: rec.ID,
|
||||
GenreID: genre.ID,
|
||||
}); err != nil {
|
||||
t.Fatalf("create recording genre: %v", err)
|
||||
}
|
||||
|
||||
// Audio file.
|
||||
af, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{
|
||||
FilePath: filePath,
|
||||
LengthMilliseconds: 180000,
|
||||
FileTypeID: 0,
|
||||
RecordingID: rec.ID,
|
||||
Basename: filepath.Base(filePath),
|
||||
LibraryID: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create audio file: %v", err)
|
||||
}
|
||||
|
||||
// Seed FTS5 search index.
|
||||
// SAFETY: FTS5 INSERT for test setup. All values parameterized.
|
||||
tx, err := db.BeginTx()
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO search_index(rowid, file_path, title, artist, album)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
af.ID, filePath, "Old Title", "Old Artist", "Old Album",
|
||||
); err != nil {
|
||||
t.Fatalf("insert search index: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
return af.ID
|
||||
}
|
||||
|
||||
// createPipelineTestMP3 creates a minimal MP3 file for pipeline tests
|
||||
@@ -253,20 +157,10 @@ func TestWriteTrackTags_OrphanCleanup(t *testing.T) {
|
||||
// Get old artist credit ID before change.
|
||||
ctx := context.Background()
|
||||
|
||||
af, err := db.Queries.GetAudioFile(ctx, trackID)
|
||||
if err != nil {
|
||||
t.Fatalf("get audio file: %v", err)
|
||||
}
|
||||
|
||||
oldRec, err := db.Queries.GetRecording(ctx, af.RecordingID)
|
||||
if err != nil {
|
||||
t.Fatalf("get recording: %v", err)
|
||||
}
|
||||
|
||||
oldACID := oldRec.ArtistCreditID
|
||||
|
||||
// Change both artist and album so the old credit loses all
|
||||
// references (recordings AND release_groups.album_artist_credit_id).
|
||||
// Change both artist and album. Under the old schema this
|
||||
// relinked a recording to a new credit and left the previous
|
||||
// credit, its artist link and its release group behind, which is
|
||||
// what three orphan sweeps in syncDatabase existed to clean up.
|
||||
if err := tw.WriteTrackTags(trackID, TagChanges{
|
||||
FieldArtist: "New Artist",
|
||||
FieldAlbum: "New Album",
|
||||
@@ -274,37 +168,26 @@ func TestWriteTrackTags_OrphanCleanup(t *testing.T) {
|
||||
t.Fatalf("WriteTrackTags: %v", err)
|
||||
}
|
||||
|
||||
// Verify old artist credit is orphaned and deleted.
|
||||
refCount, err := db.Queries.CountArtistCreditReferences(ctx, oldACID)
|
||||
af, err := db.Queries.GetAudioFile(ctx, trackID)
|
||||
if err != nil {
|
||||
t.Fatalf("count refs: %v", err)
|
||||
t.Fatalf("get audio file: %v", err)
|
||||
}
|
||||
|
||||
if refCount != 0 {
|
||||
// The old AC should have 0 references. Check if it still exists.
|
||||
_, acErr := db.Queries.GetArtistCredit(ctx, oldACID)
|
||||
if acErr == nil {
|
||||
t.Errorf(
|
||||
"expected old artist credit %d to be deleted (orphan), "+
|
||||
"but it still exists with %d refs",
|
||||
oldACID, refCount,
|
||||
)
|
||||
}
|
||||
if af.ArtistCredit != "New Artist" {
|
||||
t.Errorf("artist credit: got %q, want %q", af.ArtistCredit, "New Artist")
|
||||
}
|
||||
|
||||
// Verify new recording has new artist credit.
|
||||
newRec, err := db.Queries.GetRecording(ctx, af.RecordingID)
|
||||
if !af.AlbumID.Valid {
|
||||
t.Fatal("file has no album after the write")
|
||||
}
|
||||
|
||||
album, err := db.Queries.GetAlbum(ctx, af.AlbumID.Int64)
|
||||
if err != nil {
|
||||
t.Fatalf("get new recording: %v", err)
|
||||
t.Fatalf("get album: %v", err)
|
||||
}
|
||||
|
||||
newAC, err := db.Queries.GetArtistCredit(ctx, newRec.ArtistCreditID)
|
||||
if err != nil {
|
||||
t.Fatalf("get new artist credit: %v", err)
|
||||
}
|
||||
|
||||
if newAC.Text != "New Artist" {
|
||||
t.Errorf("new artist credit: got %q, want %q", newAC.Text, "New Artist")
|
||||
if album.Name != "New Album" {
|
||||
t.Errorf("album name: got %q, want %q", album.Name, "New Album")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,13 +215,8 @@ func TestWriteTrackTags_GenreRelink(t *testing.T) {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
af, err := db.Queries.GetAudioFile(ctx, trackID)
|
||||
if err != nil {
|
||||
t.Fatalf("get audio file: %v", err)
|
||||
}
|
||||
|
||||
// Verify new genres are linked.
|
||||
genres, err := db.Queries.GetGenresByRecordingID(ctx, af.RecordingID)
|
||||
genres, err := db.Queries.GetGenreNamesByFile(ctx, trackID)
|
||||
if err != nil {
|
||||
t.Fatalf("get genres: %v", err)
|
||||
}
|
||||
@@ -349,7 +227,7 @@ func TestWriteTrackTags_GenreRelink(t *testing.T) {
|
||||
|
||||
genreNames := make(map[string]bool)
|
||||
for _, g := range genres {
|
||||
genreNames[g.Name] = true
|
||||
genreNames[g] = true
|
||||
}
|
||||
|
||||
if !genreNames["Jazz"] {
|
||||
@@ -360,15 +238,10 @@ func TestWriteTrackTags_GenreRelink(t *testing.T) {
|
||||
t.Error("expected Blues genre to be linked")
|
||||
}
|
||||
|
||||
// Verify old "Rock" genre is deleted (orphaned).
|
||||
oldRockRefs, err := db.Queries.CountGenreReferences(ctx, 1) // ID 1 from seeding
|
||||
if err != nil {
|
||||
// Genre might not exist anymore, which is expected.
|
||||
return
|
||||
}
|
||||
|
||||
if oldRockRefs > 0 {
|
||||
t.Logf("Rock genre still has %d refs (may be expected if reused)", oldRockRefs)
|
||||
// The old "Rock" link is gone: genres are relinked wholesale, so a
|
||||
// genre dropped from the tag is dropped from the file.
|
||||
if genreNames["Rock"] {
|
||||
t.Error("expected the old Rock link to be replaced")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,65 +272,48 @@ func TestWriteTrackTags_DBSync(t *testing.T) {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Verify recording updated.
|
||||
// Verify the file's own tag columns.
|
||||
af, err := db.Queries.GetAudioFile(ctx, trackID)
|
||||
if err != nil {
|
||||
t.Fatalf("get audio file: %v", err)
|
||||
}
|
||||
|
||||
rec, err := db.Queries.GetRecording(ctx, af.RecordingID)
|
||||
if af.Title != "New Title" {
|
||||
t.Errorf("title: got %q, want %q", af.Title, "New Title")
|
||||
}
|
||||
|
||||
if af.Year.Int64 != 2025 {
|
||||
t.Errorf("year: got %d, want 2025", af.Year.Int64)
|
||||
}
|
||||
|
||||
if af.Composer != "New Composer" {
|
||||
t.Errorf("composer: got %q, want %q", af.Composer, "New Composer")
|
||||
}
|
||||
|
||||
if af.ArtistCredit != "New Artist" {
|
||||
t.Errorf("artist credit: got %q, want %q", af.ArtistCredit, "New Artist")
|
||||
}
|
||||
|
||||
if !af.AlbumID.Valid {
|
||||
t.Fatal("file has no album after the write")
|
||||
}
|
||||
|
||||
album, err := db.Queries.GetAlbum(ctx, af.AlbumID.Int64)
|
||||
if err != nil {
|
||||
t.Fatalf("get recording: %v", err)
|
||||
t.Fatalf("get album: %v", err)
|
||||
}
|
||||
|
||||
if rec.Name != "New Title" {
|
||||
t.Errorf("recording name: got %q, want %q", rec.Name, "New Title")
|
||||
}
|
||||
|
||||
if rec.Year.Int64 != 2025 {
|
||||
t.Errorf("year: got %d, want 2025", rec.Year.Int64)
|
||||
}
|
||||
|
||||
if rec.Composer.String != "New Composer" {
|
||||
t.Errorf("composer: got %q, want %q", rec.Composer.String, "New Composer")
|
||||
}
|
||||
|
||||
// Verify new artist credit.
|
||||
ac, err := db.Queries.GetArtistCredit(ctx, rec.ArtistCreditID)
|
||||
if err != nil {
|
||||
t.Fatalf("get artist credit: %v", err)
|
||||
}
|
||||
|
||||
if ac.Text != "New Artist" {
|
||||
t.Errorf("artist credit: got %q, want %q", ac.Text, "New Artist")
|
||||
}
|
||||
|
||||
// Verify new release group linked.
|
||||
rgLinks, err := db.Queries.GetRecordingReleaseGroups(ctx, rec.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get rg links: %v", err)
|
||||
}
|
||||
|
||||
if len(rgLinks) != 1 {
|
||||
t.Fatalf("expected 1 rg link, got %d", len(rgLinks))
|
||||
}
|
||||
|
||||
rg, err := db.Queries.GetReleaseGroup(ctx, rgLinks[0].ReleaseGroupID)
|
||||
if err != nil {
|
||||
t.Fatalf("get release group: %v", err)
|
||||
}
|
||||
|
||||
if rg.Name != "New Album" {
|
||||
t.Errorf("release group name: got %q, want %q", rg.Name, "New Album")
|
||||
if album.Name != "New Album" {
|
||||
t.Errorf("album name: got %q, want %q", album.Name, "New Album")
|
||||
}
|
||||
|
||||
// Verify genre re-linked.
|
||||
genres, err := db.Queries.GetGenresByRecordingID(ctx, rec.ID)
|
||||
genres, err := db.Queries.GetGenreNamesByFile(ctx, trackID)
|
||||
if err != nil {
|
||||
t.Fatalf("get genres: %v", err)
|
||||
}
|
||||
|
||||
if len(genres) != 1 || genres[0].Name != "Electronic" {
|
||||
if len(genres) != 1 || genres[0] != "Electronic" {
|
||||
t.Errorf("genres: got %v, want [Electronic]", genres)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user