- Wire introdb marker fetching into playback and Jellyfin compat - Persist and expose recap/preview markers alongside intro and credits - Add new playback/profile settings for recap and preview behavior
357 lines
12 KiB
Go
357 lines
12 KiB
Go
package userdb
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// Profile is an alias for the canonical type in userstore.
|
|
type Profile = userstore.Profile
|
|
|
|
// UpdateProfileInput is an alias for the canonical type in userstore.
|
|
type UpdateProfileInput = userstore.UpdateProfileInput
|
|
|
|
// CreateProfile inserts a new profile into the profiles table.
|
|
// The Profile.ID field must be set before calling this function; if empty,
|
|
// a new UUID is generated. CreatedAt and UpdatedAt are set to the current
|
|
// UTC time if not already populated.
|
|
func CreateProfile(db *sql.DB, p Profile) error {
|
|
if p.ID == "" {
|
|
p.ID = generateUUID()
|
|
}
|
|
now := nowUTC()
|
|
if p.CreatedAt == "" {
|
|
p.CreatedAt = now
|
|
}
|
|
if p.UpdatedAt == "" {
|
|
p.UpdatedAt = now
|
|
}
|
|
if !p.ShowForcedSubtitles {
|
|
p.ShowForcedSubtitles = true
|
|
}
|
|
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("beginning transaction for profile insert %s: %w", p.ID, err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
// The first profile in this per-user database is the primary.
|
|
var hasExisting bool
|
|
if err := tx.QueryRow("SELECT EXISTS(SELECT 1 FROM profiles)").Scan(&hasExisting); err != nil {
|
|
return fmt.Errorf("checking existing profiles for %s: %w", p.ID, err)
|
|
}
|
|
if !hasExisting {
|
|
p.IsPrimary = true
|
|
} else {
|
|
p.IsPrimary = false
|
|
}
|
|
|
|
_, err = tx.Exec(`
|
|
INSERT INTO profiles (
|
|
id, name, avatar, pin_hash, is_child, is_primary, max_content_rating,
|
|
quality_preference, language, subtitle_language, subtitle_mode,
|
|
auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview,
|
|
show_forced_subtitles,
|
|
library_restrictions_enabled, max_playback_quality, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
p.ID, p.Name, p.Avatar, p.PINHash, p.IsChild, p.IsPrimary, p.MaxContentRating,
|
|
p.QualityPreference, p.Language, p.SubtitleLanguage, p.SubtitleMode,
|
|
p.AutoSkipIntro, p.AutoSkipCredits, p.AutoSkipRecap, p.AutoPlayNextPreview,
|
|
p.ShowForcedSubtitles, p.LibraryRestrictionsEnabled,
|
|
p.MaxPlaybackQuality, p.CreatedAt, p.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("inserting profile %s: %w", p.ID, err)
|
|
}
|
|
if err := replaceProfileAllowedLibrariesTx(tx, p.ID, p.AllowedLibraryIDs); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// GetProfile retrieves a single profile by ID. Returns nil and no error if
|
|
// the profile does not exist.
|
|
func GetProfile(db *sql.DB, id string) (*Profile, error) {
|
|
var p Profile
|
|
err := db.QueryRow(`
|
|
SELECT id, name, avatar, pin_hash, is_child, is_primary, max_content_rating,
|
|
quality_preference, language, subtitle_language, subtitle_mode,
|
|
auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview, show_forced_subtitles,
|
|
library_restrictions_enabled, max_playback_quality, created_at, updated_at
|
|
FROM profiles WHERE id = ?`, id,
|
|
).Scan(
|
|
&p.ID, &p.Name, &p.Avatar, &p.PINHash, &p.IsChild, &p.IsPrimary, &p.MaxContentRating,
|
|
&p.QualityPreference, &p.Language, &p.SubtitleLanguage, &p.SubtitleMode,
|
|
&p.AutoSkipIntro, &p.AutoSkipCredits, &p.AutoSkipRecap, &p.AutoPlayNextPreview, &p.ShowForcedSubtitles,
|
|
&p.LibraryRestrictionsEnabled, &p.MaxPlaybackQuality, &p.CreatedAt, &p.UpdatedAt,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying profile %s: %w", id, err)
|
|
}
|
|
p.AllowedLibraryIDs, err = listProfileAllowedLibraries(db, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
// ListProfiles returns all profiles ordered by creation time.
|
|
func ListProfiles(db *sql.DB) ([]Profile, error) {
|
|
rows, err := db.Query(`
|
|
SELECT id, name, avatar, pin_hash, is_child, is_primary, max_content_rating,
|
|
quality_preference, language, subtitle_language, subtitle_mode,
|
|
auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview, show_forced_subtitles,
|
|
library_restrictions_enabled, max_playback_quality, created_at, updated_at
|
|
FROM profiles ORDER BY created_at ASC`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing profiles: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var profiles []Profile
|
|
for rows.Next() {
|
|
var p Profile
|
|
if err := rows.Scan(
|
|
&p.ID, &p.Name, &p.Avatar, &p.PINHash, &p.IsChild, &p.IsPrimary, &p.MaxContentRating,
|
|
&p.QualityPreference, &p.Language, &p.SubtitleLanguage, &p.SubtitleMode,
|
|
&p.AutoSkipIntro, &p.AutoSkipCredits, &p.AutoSkipRecap, &p.AutoPlayNextPreview, &p.ShowForcedSubtitles,
|
|
&p.LibraryRestrictionsEnabled, &p.MaxPlaybackQuality, &p.CreatedAt, &p.UpdatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scanning profile row: %w", err)
|
|
}
|
|
p.AllowedLibraryIDs, err = listProfileAllowedLibraries(db, p.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
profiles = append(profiles, p)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating profile rows: %w", err)
|
|
}
|
|
return profiles, nil
|
|
}
|
|
|
|
// UpdateProfile applies partial updates to a profile. Only non-nil fields
|
|
// in the UpdateProfileInput are changed. If PIN is provided, it is bcrypt-hashed
|
|
// before storage.
|
|
func UpdateProfile(db *sql.DB, id string, u UpdateProfileInput) error {
|
|
var setClauses []string
|
|
var args []any
|
|
|
|
if u.Name != nil {
|
|
setClauses = append(setClauses, "name = ?")
|
|
args = append(args, *u.Name)
|
|
}
|
|
if u.Avatar != nil {
|
|
setClauses = append(setClauses, "avatar = ?")
|
|
args = append(args, *u.Avatar)
|
|
}
|
|
if u.PIN != nil {
|
|
if *u.PIN == "" {
|
|
// Empty string clears the PIN rather than hashing an empty value.
|
|
setClauses = append(setClauses, "pin_hash = ?")
|
|
args = append(args, "")
|
|
} else {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(*u.PIN), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("hashing PIN: %w", err)
|
|
}
|
|
setClauses = append(setClauses, "pin_hash = ?")
|
|
args = append(args, string(hash))
|
|
}
|
|
}
|
|
if u.IsChild != nil {
|
|
setClauses = append(setClauses, "is_child = ?")
|
|
args = append(args, *u.IsChild)
|
|
}
|
|
if u.MaxContentRating != nil {
|
|
setClauses = append(setClauses, "max_content_rating = ?")
|
|
args = append(args, *u.MaxContentRating)
|
|
}
|
|
if u.QualityPreference != nil {
|
|
setClauses = append(setClauses, "quality_preference = ?")
|
|
args = append(args, *u.QualityPreference)
|
|
}
|
|
if u.Language != nil {
|
|
setClauses = append(setClauses, "language = ?")
|
|
args = append(args, *u.Language)
|
|
}
|
|
if u.SubtitleLanguage != nil {
|
|
setClauses = append(setClauses, "subtitle_language = ?")
|
|
args = append(args, *u.SubtitleLanguage)
|
|
}
|
|
if u.SubtitleMode != nil {
|
|
setClauses = append(setClauses, "subtitle_mode = ?")
|
|
args = append(args, *u.SubtitleMode)
|
|
}
|
|
if u.AutoSkipIntro != nil {
|
|
setClauses = append(setClauses, "auto_skip_intro = ?")
|
|
args = append(args, *u.AutoSkipIntro)
|
|
}
|
|
if u.AutoSkipRecap != nil {
|
|
setClauses = append(setClauses, "auto_skip_recap = ?")
|
|
args = append(args, *u.AutoSkipRecap)
|
|
}
|
|
if u.AutoPlayNextPreview != nil {
|
|
setClauses = append(setClauses, "auto_play_next_preview = ?")
|
|
args = append(args, *u.AutoPlayNextPreview)
|
|
}
|
|
if u.AutoSkipCredits != nil {
|
|
setClauses = append(setClauses, "auto_skip_credits = ?")
|
|
args = append(args, *u.AutoSkipCredits)
|
|
}
|
|
if u.ShowForcedSubtitles != nil {
|
|
setClauses = append(setClauses, "show_forced_subtitles = ?")
|
|
args = append(args, *u.ShowForcedSubtitles)
|
|
}
|
|
if u.LibraryRestrictionsEnabled != nil {
|
|
setClauses = append(setClauses, "library_restrictions_enabled = ?")
|
|
args = append(args, *u.LibraryRestrictionsEnabled)
|
|
}
|
|
if u.MaxPlaybackQuality != nil {
|
|
setClauses = append(setClauses, "max_playback_quality = ?")
|
|
args = append(args, *u.MaxPlaybackQuality)
|
|
}
|
|
|
|
if len(setClauses) == 0 && u.AllowedLibraryIDs == nil {
|
|
return nil // nothing to update
|
|
}
|
|
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("beginning transaction for profile update %s: %w", id, err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
var exists bool
|
|
if err := tx.QueryRow("SELECT EXISTS(SELECT 1 FROM profiles WHERE id = ?)", id).Scan(&exists); err != nil {
|
|
return fmt.Errorf("checking profile %s existence: %w", id, err)
|
|
}
|
|
if !exists {
|
|
return fmt.Errorf("profile %s not found", id)
|
|
}
|
|
|
|
if len(setClauses) > 0 {
|
|
// Always update the timestamp when profile attributes change.
|
|
setClauses = append(setClauses, "updated_at = ?")
|
|
args = append(args, nowUTC())
|
|
|
|
// WHERE clause.
|
|
args = append(args, id)
|
|
|
|
query := fmt.Sprintf("UPDATE profiles SET %s WHERE id = ?", strings.Join(setClauses, ", "))
|
|
result, err := tx.Exec(query, args...)
|
|
if err != nil {
|
|
return fmt.Errorf("updating profile %s: %w", id, err)
|
|
}
|
|
rows, err := result.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("checking rows affected for profile %s: %w", id, err)
|
|
}
|
|
if rows == 0 {
|
|
return fmt.Errorf("profile %s not found", id)
|
|
}
|
|
}
|
|
|
|
if u.AllowedLibraryIDs != nil {
|
|
if err := replaceProfileAllowedLibrariesTx(tx, id, *u.AllowedLibraryIDs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// DeleteProfile removes a profile and cascades deletion to favorites,
|
|
// watchlist, watch_progress, and personal_collections (plus their items).
|
|
func DeleteProfile(db *sql.DB, id string) error {
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("beginning transaction for profile delete: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
// Delete personal_collection_items for collections owned by this profile.
|
|
_, err = tx.Exec(`
|
|
DELETE FROM personal_collection_items
|
|
WHERE collection_id IN (
|
|
SELECT id FROM personal_collections WHERE creator_profile_id = ?
|
|
)`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting collection items for profile %s: %w", id, err)
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM personal_collection_profiles WHERE profile_id = ?`, id); err != nil {
|
|
return fmt.Errorf("deleting collection visibility for profile %s: %w", id, err)
|
|
}
|
|
|
|
// Cascade-delete related tables.
|
|
cascadeTables := []string{
|
|
"favorites",
|
|
"watchlist",
|
|
"watch_progress",
|
|
"personal_collections",
|
|
"profile_allowed_libraries",
|
|
"series_playback_preferences",
|
|
"library_playback_preferences",
|
|
}
|
|
for _, table := range cascadeTables {
|
|
column := "profile_id"
|
|
if table == "personal_collections" {
|
|
column = "creator_profile_id"
|
|
}
|
|
if _, err := tx.Exec(fmt.Sprintf("DELETE FROM %s WHERE %s = ?", table, column), id); err != nil {
|
|
return fmt.Errorf("deleting from %s for profile %s: %w", table, id, err)
|
|
}
|
|
}
|
|
|
|
// Delete the profile itself.
|
|
result, err := tx.Exec("DELETE FROM profiles WHERE id = ?", id)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting profile %s: %w", id, err)
|
|
}
|
|
rows, err := result.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("checking rows affected for profile %s: %w", id, err)
|
|
}
|
|
if rows == 0 {
|
|
return fmt.Errorf("profile %s not found", id)
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// VerifyPIN checks a plaintext PIN against the bcrypt hash stored for a
|
|
// profile. Returns true if the PIN matches, false otherwise. Returns an
|
|
// error if the profile is not found or has no PIN set.
|
|
func VerifyPIN(db *sql.DB, profileID string, pin string) (bool, error) {
|
|
var pinHash string
|
|
err := db.QueryRow("SELECT pin_hash FROM profiles WHERE id = ?", profileID).Scan(&pinHash)
|
|
if err == sql.ErrNoRows {
|
|
return false, fmt.Errorf("profile %s not found", profileID)
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("querying PIN hash for profile %s: %w", profileID, err)
|
|
}
|
|
if pinHash == "" {
|
|
return false, fmt.Errorf("profile %s has no PIN set", profileID)
|
|
}
|
|
|
|
err = bcrypt.CompareHashAndPassword([]byte(pinHash), []byte(pin))
|
|
if err == bcrypt.ErrMismatchedHashAndPassword {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("comparing PIN for profile %s: %w", profileID, err)
|
|
}
|
|
return true, nil
|
|
}
|