mirror of
https://codeberg.org/VnPower/PixivFE
synced 2024-12-06 19:16:23 +01:00
787 lines
24 KiB
Go
787 lines
24 KiB
Go
package core
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"codeberg.org/vnpower/pixivfe/v2/audit"
|
|
"codeberg.org/vnpower/pixivfe/v2/i18n"
|
|
"codeberg.org/vnpower/pixivfe/v2/server/session"
|
|
"github.com/goccy/go-json"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
// UserWorkCategory represents a unified structure for handling different work categories.
|
|
type UserWorkCategory struct {
|
|
Value string // Category identifier (e.g., "illustrations", "manga")
|
|
PageLimit int // Maximum number of pages for pagination
|
|
FrequentTags []FrequentTag // Frequently used tags within the category
|
|
IllustWorks []ArtworkBrief // Populated if the work type requires ArtworkBrief
|
|
NovelWorks []NovelBrief // Populated if the work type requires NovelBrief
|
|
WorkIDs string // Concatenated string of work IDs
|
|
WorkCount int // Number of works for the category
|
|
MangaSeries []MangaSeries // Populated for the "manga" category
|
|
NovelSeries []NovelSeries // Populated for the "novels" category
|
|
}
|
|
|
|
var (
|
|
CategoryAny = &UserWorkCategory{Value: ""}
|
|
CategoryAnyAlt = &UserWorkCategory{Value: "artworks"}
|
|
CategoryIllustration = &UserWorkCategory{Value: "illustrations"}
|
|
CategoryManga = &UserWorkCategory{Value: "manga"}
|
|
CategoryBookmarks = &UserWorkCategory{Value: "bookmarks"}
|
|
CategoryNovels = &UserWorkCategory{Value: "novels"}
|
|
)
|
|
|
|
// NewUserWorkCategory creates a new UserWorkCategory with the given value
|
|
func NewUserWorkCategory(value string) UserWorkCategory {
|
|
return UserWorkCategory{
|
|
Value: value,
|
|
IllustWorks: nil,
|
|
WorkCount: 0,
|
|
}
|
|
}
|
|
|
|
func (s UserWorkCategory) Validate() error {
|
|
validValues := []string{
|
|
CategoryAny.Value,
|
|
CategoryAnyAlt.Value,
|
|
CategoryIllustration.Value,
|
|
CategoryManga.Value,
|
|
CategoryBookmarks.Value,
|
|
CategoryNovels.Value,
|
|
}
|
|
|
|
for _, val := range validValues {
|
|
if s.Value == val {
|
|
return nil
|
|
}
|
|
}
|
|
return i18n.Errorf(`Invalid work category: %#v. Only "%s", "%s", "%s", "%s", "%s" and "%s" are available`,
|
|
s.Value,
|
|
CategoryAny.Value,
|
|
CategoryAnyAlt.Value,
|
|
CategoryIllustration.Value,
|
|
CategoryManga.Value,
|
|
CategoryBookmarks.Value,
|
|
CategoryNovels.Value,
|
|
)
|
|
}
|
|
|
|
func (c *UserWorkCategory) SetPageLimit(limit int) {
|
|
c.PageLimit = limit
|
|
}
|
|
|
|
type FrequentTag struct {
|
|
Name string `json:"tag"`
|
|
TranslatedName string `json:"tag_translation"`
|
|
}
|
|
|
|
// User represents a user.
|
|
type User struct {
|
|
ID string `json:"userId"`
|
|
Name string `json:"name"`
|
|
Image string `json:"image"` // Unimplemented
|
|
Avatar string `json:"imageBig"` // Higher resolution avatar
|
|
Premium bool `json:"premium"` // Unimplemented
|
|
IsFollowed bool `json:"isFollowed"` // Whether the logged-in user is following this user
|
|
IsMyPixiv bool `json:"isMypixiv"` // Unimplemented
|
|
IsBlocking bool `json:"isBlocking"` // Unimplemented
|
|
Background map[string]any `json:"background"`
|
|
SketchLiveID string `json:"sketchLiveId"` // Unimplemented
|
|
Partial int `json:"partial"` // Unimplemented
|
|
SketchLives []any `json:"sketchLives"` // Unimplemented
|
|
Commission any `json:"commission"` // Unimplemented
|
|
Following int `json:"following"`
|
|
MyPixiv int `json:"mypixivCount"`
|
|
FollowedBack bool `json:"followedBack"` // Unimplemented
|
|
Comment string `json:"comment"` // Biography
|
|
CommentHTML HTML `json:"commentHtml"` // HTML-formatted biography
|
|
Webpage string `json:"webpage"`
|
|
SocialRaw json.RawMessage `json:"social"`
|
|
// The following fields are currently unimplemented in PixivFE
|
|
CanSendMessage bool `json:"canSendMessage"`
|
|
Region struct {
|
|
Name string
|
|
Region string
|
|
Prefecture string
|
|
PrivacyLevel string
|
|
} `json:"region"`
|
|
Age struct {
|
|
Name string
|
|
PrivacyLevel string
|
|
} `json:"age"`
|
|
BirthDay struct {
|
|
Name string
|
|
PrivacyLevel string
|
|
} `json:"birthDay"`
|
|
Gender struct {
|
|
Name string
|
|
PrivacyLevel string
|
|
} `json:"gender"`
|
|
Job struct {
|
|
Name string
|
|
PrivacyLevel string
|
|
} `json:"job"`
|
|
Workspace struct {
|
|
UserWorkspaceTool string
|
|
UserWorkspaceTablet string
|
|
} `json:"workspace"`
|
|
Official bool `json:"official"`
|
|
Group any `json:"group"`
|
|
|
|
// The following fields are internal to PixivFE
|
|
Social map[string]map[string]string
|
|
BackgroundImage string
|
|
Categories map[string]*UserWorkCategory // Holds Artworks, Illustrations, Manga, Novels, NovelSeries, and MangaSeries
|
|
Artworks []ArtworkBrief // Used on user discovery page only
|
|
Novels []NovelBrief // Used on user discovery page only
|
|
}
|
|
|
|
// GetUserProfile retrieves the user profile, including counts, artworks/bookmarks, and social data.
|
|
//
|
|
// Goroutines are used to avoid blocking on network requests.
|
|
func GetUserProfile(auditor *audit.Auditor, r *http.Request, id string, currentCategory *UserWorkCategory, page int, getTags bool, mode string) (User, error) {
|
|
auditor.Logger.Debug("GetUserProfile called",
|
|
zap.String("userID", id),
|
|
zap.String("currentCategory", currentCategory.Value),
|
|
zap.Int("page", page),
|
|
zap.Bool("getTags", getTags),
|
|
)
|
|
|
|
var userInfo User
|
|
var categories map[string]*UserWorkCategory
|
|
var g errgroup.Group
|
|
|
|
// Goroutine to fetch user information
|
|
g.Go(func() error {
|
|
// Fetch user information
|
|
userInfoURL := GetUserInformationURL(id)
|
|
auditor.Logger.Debug("Fetching user information", zap.String("URL", userInfoURL))
|
|
token := session.GetUserToken(r)
|
|
resp, err := API_GET_UnwrapJson(r.Context(), auditor, userInfoURL, token, r.Header)
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to fetch user information", zap.Error(err))
|
|
return err
|
|
}
|
|
|
|
resp, err = session.ProxyImageUrl(r, resp)
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to proxy image URL", zap.Error(err))
|
|
return err
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(resp), &userInfo); err != nil {
|
|
auditor.SugaredLogger.Error("Failed to unmarshal user information", zap.Error(err))
|
|
return err
|
|
}
|
|
|
|
// Parse social data
|
|
auditor.Logger.Debug("Parsing social data")
|
|
if err := userInfo.parseSocial(); err != nil {
|
|
auditor.SugaredLogger.Error("Failed to parse social data", zap.Error(err))
|
|
return err
|
|
}
|
|
auditor.Logger.Debug("Social data parsed", zap.Any("social", userInfo.Social))
|
|
|
|
// Set background image if available
|
|
if userInfo.Background != nil {
|
|
if url, ok := userInfo.Background["url"].(string); ok {
|
|
userInfo.BackgroundImage = url
|
|
auditor.Logger.Debug("Background image set", zap.String("BackgroundImage", url))
|
|
} else {
|
|
auditor.SugaredLogger.Warn("Background URL not found or invalid")
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// Goroutine to fetch works and populate categories
|
|
g.Go(func() error {
|
|
var err error
|
|
auditor.Logger.Debug("Getting populated works")
|
|
categories, err = getPopulatedWorks(auditor, r, id, currentCategory, page, getTags, mode)
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to get populated works", zap.Error(err))
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// Wait for both goroutines to finish
|
|
if err := g.Wait(); err != nil {
|
|
return User{}, err
|
|
}
|
|
|
|
// Merge userInfo and categories into the final user struct
|
|
user := userInfo
|
|
user.Categories = categories
|
|
|
|
return user, nil
|
|
}
|
|
|
|
// getPopulatedWorks fetches then populates information for a user's works, as well as handling their
|
|
// frequently used tags and series data.
|
|
//
|
|
// Goroutines are used to avoid blocking on network requests.
|
|
func getPopulatedWorks(auditor *audit.Auditor, r *http.Request, id string, currentCategory *UserWorkCategory, page int, getTags bool, mode string) (map[string]*UserWorkCategory, error) {
|
|
auditor.Logger.Debug("getPopulatedWorks called",
|
|
zap.String("userID", id),
|
|
zap.String("currentCategory", currentCategory.Value),
|
|
zap.Int("page", page),
|
|
zap.Bool("getTags", getTags),
|
|
)
|
|
|
|
// Fetch work IDs and series data
|
|
auditor.Logger.Debug("Fetching work IDs and series data for works")
|
|
categoriesData, err := fetchWorkIDsAndSeriesData(auditor, r, id, currentCategory, page)
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to fetch work IDs and series data", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
// Create an errgroup
|
|
var g errgroup.Group
|
|
|
|
// Fetch and populate works for each category in parallel
|
|
for _, cat := range categoriesData {
|
|
// Capture cat in local variable
|
|
cat := cat
|
|
|
|
// Skip categories with no work IDs (except bookmarks)
|
|
if cat.WorkIDs == "" && cat.Value != "bookmarks" {
|
|
continue
|
|
}
|
|
|
|
g.Go(func() error {
|
|
switch cat.Value {
|
|
case "illustrations", "manga":
|
|
auditor.Logger.Debug("Fetching and populating ArtworkBriefs", zap.String("category", cat.Value))
|
|
artworks, safeArtworks, err := populateArtworkIDs(auditor, r, id, cat.WorkIDs)
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to populate artwork IDs", zap.Error(err))
|
|
return err
|
|
}
|
|
if mode == "all" {
|
|
cat.IllustWorks = artworks
|
|
} else if mode == "safe" {
|
|
cat.IllustWorks = safeArtworks
|
|
}
|
|
|
|
case "novels":
|
|
auditor.Logger.Debug("Fetching and populating NovelBriefs", zap.String("category", cat.Value))
|
|
novels, safeNovels, err := populateNovelIDs(auditor, r, id, cat.WorkIDs)
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to populate novel IDs", zap.Error(err))
|
|
return err
|
|
}
|
|
if mode == "all" {
|
|
cat.NovelWorks = novels
|
|
} else if mode == "safe" {
|
|
cat.NovelWorks = safeNovels
|
|
}
|
|
|
|
case "bookmarks":
|
|
auditor.Logger.Debug("Fetching and populating Bookmarks", zap.String("category", cat.Value))
|
|
bookmarks, count, err := populateBookmarks(auditor, r, id, "show", page)
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to populate bookmarked work IDs", zap.Error(err))
|
|
return err
|
|
}
|
|
cat.IllustWorks = bookmarks
|
|
cat.WorkCount = count
|
|
worksPerPage := 48.0
|
|
cat.PageLimit = int(math.Ceil(float64(count) / worksPerPage))
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
g.Go(func() error {
|
|
// Fetch frequent tags if requested
|
|
if getTags && cat.Value != "bookmarks" {
|
|
auditor.Logger.Debug("Fetching frequent tags for category", zap.String("category", cat.Value))
|
|
tags, err := fetchFrequentTags(auditor, r, cat.WorkIDs, cat)
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to fetch frequent tags", zap.String("category", cat.Value), zap.Error(err))
|
|
return err
|
|
}
|
|
cat.FrequentTags = tags
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Wait for all goroutines to finish
|
|
if err := g.Wait(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Set PageLimit for the current category
|
|
if currentCategory != nil {
|
|
if cat, ok := categoriesData[currentCategory.Value]; ok {
|
|
currentCategory.PageLimit = cat.PageLimit
|
|
}
|
|
}
|
|
|
|
return categoriesData, nil
|
|
}
|
|
|
|
// fetchWorkIDsAndSeriesData retrieves artwork IDs and series information for a user.
|
|
//
|
|
// It populates UserWorkCategory structs for each category.
|
|
func fetchWorkIDsAndSeriesData(auditor *audit.Auditor, r *http.Request, id string, currentCategory *UserWorkCategory, page int) (map[string]*UserWorkCategory, error) {
|
|
auditor.Logger.Debug("fetchWorkIDsAndSeriesData called",
|
|
zap.String("userID", id),
|
|
zap.String("currentCategory", currentCategory.Value),
|
|
zap.Int("page", page),
|
|
)
|
|
|
|
URL := GetUserWorksURL(id)
|
|
auditor.Logger.Debug("Fetching user works", zap.String("URL", URL))
|
|
|
|
resp, err := API_GET_UnwrapJson(r.Context(), auditor, URL, "", r.Header)
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("API_GET_UnwrapJson failed", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
auditor.Logger.Debug("User works fetched", zap.String("response", resp))
|
|
|
|
resp, err = session.ProxyImageUrl(r, resp)
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to proxy image URL", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
var body struct {
|
|
Illusts *json.RawMessage `json:"illusts"`
|
|
Manga *json.RawMessage `json:"manga"`
|
|
MangaSeries *json.RawMessage `json:"mangaSeries"`
|
|
Novels *json.RawMessage `json:"novels"`
|
|
NovelSeries *json.RawMessage `json:"novelSeries"`
|
|
}
|
|
|
|
err = json.Unmarshal([]byte(resp), &body)
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to unmarshal user works response", zap.Error(err))
|
|
return nil, fmt.Errorf("failed to unmarshal response body: %w", err)
|
|
}
|
|
auditor.Logger.Debug("User works response unmarshalled")
|
|
|
|
categories := make(map[string]*UserWorkCategory)
|
|
|
|
// Process illustrations
|
|
illustCat := &UserWorkCategory{Value: "illustrations"}
|
|
illustIDs, count, err := extractIDs(body.Illusts, "illusts")
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to extract illustration IDs", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
illustCat.WorkCount = count
|
|
illustCat.WorkIDs = buildIDString(auditor, illustIDs, page, currentCategory, illustCat)
|
|
categories["illustrations"] = illustCat
|
|
|
|
// Process manga
|
|
mangaCat := &UserWorkCategory{Value: "manga"}
|
|
mangaIDs, count, err := extractIDs(body.Manga, "manga")
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to extract manga IDs", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
mangaCat.WorkCount = count
|
|
mangaCat.WorkIDs = buildIDString(auditor, mangaIDs, page, currentCategory, mangaCat)
|
|
// Handle MangaSeries
|
|
if body.MangaSeries != nil && len(*body.MangaSeries) > 0 {
|
|
err := json.Unmarshal(*body.MangaSeries, &mangaCat.MangaSeries)
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to unmarshal MangaSeries", zap.Error(err))
|
|
}
|
|
}
|
|
categories["manga"] = mangaCat
|
|
|
|
// Process novels
|
|
novelCat := &UserWorkCategory{Value: "novels"}
|
|
novelIDs, count, err := extractIDs(body.Novels, "novels")
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to extract novel IDs", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
novelCat.WorkCount = count
|
|
novelCat.WorkIDs = buildIDString(auditor, novelIDs, page, currentCategory, novelCat)
|
|
// Handle NovelSeries
|
|
if body.NovelSeries != nil && len(*body.NovelSeries) > 0 {
|
|
err := json.Unmarshal(*body.NovelSeries, &novelCat.NovelSeries)
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Failed to unmarshal NovelSeries", zap.Error(err))
|
|
}
|
|
}
|
|
categories["novels"] = novelCat
|
|
|
|
// Create an empty bookmarks category
|
|
//
|
|
// We don't build an ID string here as fetchBookmarks populates IllustWorks without the need for a WorkIDs string
|
|
// (which is also why we can't call fetchFrequentTags for the "bookmarks" category, though extracting the IDs from
|
|
// ArtworkBrief would be possible)
|
|
categories["bookmarks"] = &UserWorkCategory{Value: "bookmarks"}
|
|
|
|
return categories, nil
|
|
}
|
|
|
|
// buildIDString builds the ID string for API requests and sets the PageLimit.
|
|
func buildIDString(auditor *audit.Auditor, ids []int, page int, currentCategory, cat *UserWorkCategory) string {
|
|
sort.Sort(sort.Reverse(sort.IntSlice(ids)))
|
|
worksPerPage := 30.0
|
|
totalItems := len(ids)
|
|
|
|
// We only use the actual page number for the current category being viewed
|
|
// and default the page to 1 for other categories.
|
|
//
|
|
// This is so that we don't attempt to paginate categories that don't have enough
|
|
// items, which would raise a spurious error from computeSliceBounds regarding an
|
|
// invalid page number.
|
|
//
|
|
// NOTE: A different approach will be needed if we require pageLimits for inactive categories.
|
|
effectivePage := page
|
|
if currentCategory.Value != cat.Value {
|
|
effectivePage = 1
|
|
}
|
|
|
|
start, end, pageLimit, err := computeSliceBounds(auditor, effectivePage, worksPerPage, totalItems)
|
|
if err != nil {
|
|
auditor.SugaredLogger.Error("Error computing slice bounds", zap.Error(err))
|
|
return ""
|
|
}
|
|
|
|
if currentCategory.Value == cat.Value {
|
|
cat.SetPageLimit(pageLimit)
|
|
}
|
|
|
|
idsToUse := ids[start:end]
|
|
var idsBuilder strings.Builder
|
|
for _, k := range idsToUse {
|
|
idsBuilder.WriteString(fmt.Sprintf("&ids[]=%d", k))
|
|
}
|
|
return idsBuilder.String()
|
|
}
|
|
|
|
// extractIDs is a helper function to extract IDs and count from a raw JSON message
|
|
func extractIDs(rawMessage *json.RawMessage, categoryName string) ([]int, int, error) {
|
|
var dataMap IntStringMap
|
|
if rawMessage != nil && len(*rawMessage) > 0 {
|
|
if err := json.Unmarshal(*rawMessage, &dataMap); err != nil {
|
|
fmt.Printf("Error unmarshalling %s: %v\n", categoryName, err)
|
|
dataMap = make(IntStringMap)
|
|
}
|
|
} else {
|
|
dataMap = make(IntStringMap)
|
|
}
|
|
|
|
ids := make([]int, 0, len(dataMap))
|
|
for k := range dataMap {
|
|
ids = append(ids, k)
|
|
}
|
|
return ids, len(dataMap), nil
|
|
}
|
|
|
|
// fetchFrequentTags fetches a user's frequently used tags, based on category.
|
|
func fetchFrequentTags(auditor *audit.Auditor, r *http.Request, ids string, workCategory *UserWorkCategory) ([]FrequentTag, error) {
|
|
var tags []FrequentTag
|
|
var URL string
|
|
|
|
switch workCategory.Value {
|
|
case "illustrations", "manga":
|
|
URL = GetUserFrequentArtworkTagsURL(ids)
|
|
case "novels":
|
|
URL = GetUserFrequentNovelTagsURL(ids)
|
|
default:
|
|
return tags, fmt.Errorf("unsupported category: %s", workCategory.Value)
|
|
}
|
|
|
|
// Return early if there are no IDs
|
|
//
|
|
// NOTE: theoretically, this check should never evaluate as true since fetchUserWorks doesn't call
|
|
// getUserFrequentTags when len(tagsIDs) == 0, instead returning an empty []FrequentTag
|
|
if ids == "" {
|
|
return tags, nil
|
|
}
|
|
|
|
response, err := API_GET_UnwrapJson(r.Context(), auditor, URL, "", r.Header)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = json.Unmarshal([]byte(response), &tags)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return tags, nil
|
|
}
|
|
|
|
// Work is a generic type constraint.
|
|
type Work interface{}
|
|
|
|
// populateWorkIDs is a generic helper function to populate a []Work for a given set of work IDs.
|
|
//
|
|
// Each work ID should be in the format `&ids[]=123456`.
|
|
func populateWorkIDs[T Work](auditor *audit.Auditor, r *http.Request, url string) ([]T, error) {
|
|
resp, err := API_GET_UnwrapJson(r.Context(), auditor, url, "", r.Header)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err = session.ProxyImageUrl(r, resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Define a generic body structure.
|
|
var body struct {
|
|
Works map[int]json.RawMessage `json:"works"`
|
|
}
|
|
|
|
// Unmarshal the response into the body.
|
|
if err := json.Unmarshal([]byte(resp), &body); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Initialize the slice to hold the works.
|
|
var works []T
|
|
|
|
// Iterate over each work and unmarshal into the specific type.
|
|
for _, v := range body.Works {
|
|
var work T
|
|
if err := json.Unmarshal(v, &work); err != nil {
|
|
return nil, err
|
|
}
|
|
works = append(works, work)
|
|
}
|
|
|
|
return works, nil
|
|
}
|
|
|
|
// populateArtworkIDs populates a []ArtworkBrief and a []ArtworkBrief containing only safe works (XRestrict == 0) for a given set of artwork IDs.
|
|
func populateArtworkIDs(auditor *audit.Auditor, r *http.Request, id, ids string) ([]ArtworkBrief, []ArtworkBrief, error) {
|
|
works, err := populateWorkIDs[ArtworkBrief](auditor, r, GetUserFullArtworkURL(id, ids))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Sort the works based on ID in descending order
|
|
sort.Slice(works, func(i, j int) bool {
|
|
return numberGreaterThan(works[i].ID, works[j].ID)
|
|
})
|
|
|
|
// Populate thumbnails for each artwork
|
|
for idx := range works {
|
|
artwork := &works[idx]
|
|
if err := artwork.PopulateThumbnails(auditor); err != nil {
|
|
auditor.SugaredLogger.Errorf("Failed to populate thumbnails for artwork ID %s: %v", artwork.ID, err)
|
|
return nil, nil, fmt.Errorf("failed to populate thumbnails for artwork ID %s: %w", artwork.ID, err)
|
|
}
|
|
}
|
|
|
|
// Filter works to create safeWorks where XRestrict == 0
|
|
safeWorks := make([]ArtworkBrief, 0, len(works))
|
|
for _, artwork := range works {
|
|
if artwork.XRestrict == 0 {
|
|
safeWorks = append(safeWorks, artwork)
|
|
}
|
|
}
|
|
|
|
return works, safeWorks, nil
|
|
}
|
|
|
|
// populateNovelIDs populates a []NovelBrief and a []NovelBrief containing only safe works (XRestrict == 0) for a given set of novel IDs.
|
|
func populateNovelIDs(auditor *audit.Auditor, r *http.Request, id, ids string) ([]NovelBrief, []NovelBrief, error) {
|
|
// Fetch the novels using the existing populateWorkIDs function
|
|
works, err := populateWorkIDs[NovelBrief](auditor, r, GetUserFullNovelURL(id, ids))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Sort the works based on ID in descending order
|
|
sort.Slice(works, func(i, j int) bool {
|
|
return numberGreaterThan(works[i].ID, works[j].ID)
|
|
})
|
|
|
|
// Filter works to create safeWorks where XRestrict == 0
|
|
safeWorks := make([]NovelBrief, 0, len(works))
|
|
for _, novel := range works {
|
|
if novel.XRestrict == 0 {
|
|
safeWorks = append(safeWorks, novel)
|
|
}
|
|
}
|
|
|
|
return works, safeWorks, nil
|
|
}
|
|
|
|
// populateBookmarks populates a []ArtworkBrief for a given set of bookmarked work IDs.
|
|
//
|
|
// This function cannot be neatly refactored to use getWorkIDs due to having
|
|
// a different API response structure.
|
|
func populateBookmarks(auditor *audit.Auditor, r *http.Request, id, mode string, page int) ([]ArtworkBrief, int, error) {
|
|
page--
|
|
|
|
URL := GetUserBookmarksURL(id, mode, page)
|
|
|
|
resp, err := API_GET_UnwrapJson(r.Context(), auditor, URL, "", r.Header)
|
|
if err != nil {
|
|
return nil, -1, err
|
|
}
|
|
|
|
resp, err = session.ProxyImageUrl(r, resp)
|
|
if err != nil {
|
|
return nil, -1, err
|
|
}
|
|
|
|
var body struct {
|
|
Artworks []json.RawMessage `json:"works"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
err = json.Unmarshal([]byte(resp), &body)
|
|
if err != nil {
|
|
return nil, -1, err
|
|
}
|
|
|
|
artworks := make([]ArtworkBrief, len(body.Artworks))
|
|
|
|
for index, value := range body.Artworks {
|
|
var artwork ArtworkBrief
|
|
|
|
err = json.Unmarshal([]byte(value), &artwork)
|
|
if err != nil {
|
|
artworks[index] = ArtworkBrief{
|
|
ID: "#",
|
|
Title: "Deleted or Private",
|
|
Thumbnail: "https://s.pximg.net/common/images/limit_unknown_360.png",
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Populate thumbnails
|
|
if err := artwork.PopulateThumbnails(auditor); err != nil {
|
|
auditor.SugaredLogger.Errorf("Failed to populate thumbnails for artwork ID %s: %v", id, err)
|
|
return nil, -1, fmt.Errorf("failed to populate thumbnails for artwork ID %s: %w", id, err)
|
|
}
|
|
artworks[index] = artwork
|
|
}
|
|
|
|
return artworks, body.Total, nil
|
|
}
|
|
|
|
// computeSliceBounds is a utility function to compute slice bounds safely
|
|
func computeSliceBounds(auditor *audit.Auditor, page int, worksPerPage float64, totalItems int) (start, end, pageLimit int, err error) {
|
|
auditor.Logger.Debug("Entered computeSliceBounds",
|
|
zap.Int("page", page),
|
|
zap.Float64("worksPerPage", worksPerPage),
|
|
zap.Int("totalItems", totalItems),
|
|
)
|
|
|
|
if totalItems == 0 {
|
|
auditor.Logger.Debug("totalItems is 0, returning 0, 0, nil")
|
|
return 0, 0, 0, nil
|
|
}
|
|
|
|
pageLimit = int(math.Ceil(float64(totalItems) / worksPerPage))
|
|
auditor.Logger.Debug("Calculated pageLimit",
|
|
zap.Int("pageLimit", pageLimit),
|
|
)
|
|
|
|
if page < 1 || page > pageLimit {
|
|
auditor.Logger.Debug("Invalid page number",
|
|
zap.Int("page", page),
|
|
zap.Int("pageLimit", pageLimit),
|
|
)
|
|
return 0, 0, 0, fmt.Errorf("invalid page number")
|
|
}
|
|
|
|
start = (page - 1) * int(worksPerPage)
|
|
end = min(start+int(worksPerPage), totalItems)
|
|
|
|
auditor.Logger.Debug("Calculated slice bounds",
|
|
zap.Int("start", start),
|
|
zap.Int("end", end),
|
|
)
|
|
auditor.Logger.Debug("Set pageLimit",
|
|
zap.Int("pageLimit", pageLimit),
|
|
)
|
|
|
|
auditor.Logger.Debug("Exited computeSliceBounds successfully")
|
|
|
|
return start, end, pageLimit, nil
|
|
}
|
|
|
|
// parseSocial parses the social data for a user.
|
|
func (s *User) parseSocial() error {
|
|
if string(s.SocialRaw[:]) == "[]" {
|
|
// Fuck Pixiv
|
|
return nil
|
|
}
|
|
|
|
err := json.Unmarshal(s.SocialRaw, &s.Social)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IntStringMap is a custom type that can handle both JSON objects and empty arrays.
|
|
type IntStringMap map[int]string
|
|
|
|
// UnmarshalJSON implements custom unmarshalling for IntStringMap.
|
|
func (m *IntStringMap) UnmarshalJSON(data []byte) error {
|
|
// Check if the data is an empty array.
|
|
if string(data) == "[]" {
|
|
*m = make(map[int]string)
|
|
return nil
|
|
}
|
|
|
|
// Otherwise, expect an object.
|
|
var temp map[string]*string
|
|
if err := json.Unmarshal(data, &temp); err != nil {
|
|
return err
|
|
}
|
|
|
|
result := make(map[int]string)
|
|
for keyStr, value := range temp {
|
|
key, err := strconv.Atoi(keyStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid key '%s': %v", keyStr, err)
|
|
}
|
|
if value != nil {
|
|
result[key] = *value
|
|
} else {
|
|
result[key] = ""
|
|
}
|
|
}
|
|
|
|
*m = result
|
|
return nil
|
|
}
|
|
|
|
func numberGreaterThan(l, r string) bool {
|
|
if len(l) > len(r) {
|
|
return true
|
|
}
|
|
if len(l) < len(r) {
|
|
return false
|
|
}
|
|
return l > r
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|