From ec93fc3379d0821cc5299f61c7e6b9b8bd01e189 Mon Sep 17 00:00:00 2001 From: Neo <321592+Neo-Desktop@users.noreply.github.com> Date: Thu, 13 Oct 2022 17:37:52 -0700 Subject: [PATCH] initial commit - working code new file: .gitignore new file: .idea/.gitignore new file: .idea/Steamworks-Collection-Downloader.iml new file: .idea/modules.xml new file: .idea/vcs.xml new file: downloader/downloadable.go new file: downloader/downloader.go new file: go.mod new file: go.sum new file: main.go new file: manifest/entry.go new file: manifest/manifest.go new file: webapi/publishedFileDetailsResponse.go --- .gitignore | 210 +++++++++++++++ .idea/.gitignore | 8 + .idea/Steamworks-Collection-Downloader.iml | 9 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + downloader/downloadable.go | 13 + downloader/downloader.go | 287 +++++++++++++++++++++ go.mod | 5 + go.sum | 2 + main.go | 122 +++++++++ manifest/entry.go | 19 ++ manifest/manifest.go | 77 ++++++ webapi/publishedFileDetailsResponse.go | 67 +++++ 13 files changed, 833 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/Steamworks-Collection-Downloader.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 downloader/downloadable.go create mode 100644 downloader/downloader.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 manifest/entry.go create mode 100644 manifest/manifest.go create mode 100644 webapi/publishedFileDetailsResponse.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..513cf76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,210 @@ +/workshop +Steamworks-Collection-Downloader + +### NotepadPP template +# Notepad++ backups # +*.bak + +### VisualStudioCode template +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Linux template +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### Go template +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +### Windows template +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### SublimeText template +# Cache files for Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# Workspace files are user-specific +*.sublime-workspace + +# Project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using Sublime Text +# *.sublime-project + +# SFTP configuration file +sftp-config.json +sftp-config-alt*.json + +# Package control specific files +Package Control.last-run +Package Control.ca-list +Package Control.ca-bundle +Package Control.system-ca-bundle +Package Control.cache/ +Package Control.ca-certs/ +Package Control.merged-ca-bundle +Package Control.user-ca-bundle +oscrypto-ca-bundle.crt +bh_unicode_properties.cache + +# Sublime-github package stores a github token in this file +# https://packagecontrol.io/packages/sublime-github +GitHub.sublime-settings + diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..1c2fda5 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/Steamworks-Collection-Downloader.iml b/.idea/Steamworks-Collection-Downloader.iml new file mode 100644 index 0000000..338a266 --- /dev/null +++ b/.idea/Steamworks-Collection-Downloader.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..a6adcd9 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..9661ac7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/downloader/downloadable.go b/downloader/downloadable.go new file mode 100644 index 0000000..6979609 --- /dev/null +++ b/downloader/downloadable.go @@ -0,0 +1,13 @@ +package downloader + +import "github.com/Neo-Desktop/Steamworks-Collection-Downloader/manifest" + +type Downloadable struct { + PreviewFilename string + FileURL string + PreviewURL string + Title string + Filesize string + manifest.Entry + fetched bool +} diff --git a/downloader/downloader.go b/downloader/downloader.go new file mode 100644 index 0000000..e112c6b --- /dev/null +++ b/downloader/downloader.go @@ -0,0 +1,287 @@ +package downloader + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "os" + "time" + + "github.com/Neo-Desktop/Steamworks-Collection-Downloader/manifest" + "github.com/Neo-Desktop/Steamworks-Collection-Downloader/webapi" + + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" +) + +type Downloader struct { + apikey string + prefix string + Fetched map[string]*Downloadable +} + +func (d *Downloader) Start(apiKey string, seeds []string, prefix string) error { + d.apikey = apiKey + d.prefix = prefix + + err, r := d.RequestFileDetails(apiKey, seeds) + if err != nil { + return err + } + + for _, v := range seeds { + d.AddSeed(v) + } + + err = d.FetchCollections(r) + return err +} + +func (d *Downloader) AddSeed(id string) { + if d.Fetched == nil { + d.Fetched = make(map[string]*Downloadable) + } + d.Fetched[id] = new(Downloadable) +} + +func (d *Downloader) FetchCollections(r *webapi.PublishedFileDetailsResponse) error { + var err error + + if d.apikey == "" { + return errors.New("API key not set") + } + + for { + for _, v := range r.Response.PublishedFileDetails { + d.Fetched[v.PublishedFileID].fetched = true + + log.Printf("%s - has children (%t)\n", v.Title, v.NumChildren > 0) + if v.NumChildren > 0 { + for _, c := range v.Children { + if _, ok := d.Fetched[c.PublishedFileID]; !ok { + d.Fetched[c.PublishedFileID] = &Downloadable{fetched: false} + } + } + } + + if len(v.Filename) > 3 && v.Filename[len(v.Filename)-3:] == "vpk" { + d.Fetched[v.PublishedFileID] = &Downloadable{ + FileURL: v.FileURL, + PreviewURL: v.PreviewURL, + Title: v.Title, + Entry: manifest.Entry{ + ID: v.PublishedFileID, + TimeCreated: fmt.Sprintf("%d", v.TimeCreated), + TimeUpdated: fmt.Sprintf("%d", v.TimeUpdated), + HContentFile: v.HContentFile, + HContentFileSize: v.FileSize, + HContentPreview: v.HContentPreview, + HContentPreviewSize: v.PreviewFileSize, + }, + fetched: true, + } + } + } + + children := make([]string, 0) + for k, v := range d.Fetched { + if v.fetched == false { + children = append(children, k) + } + } + + if len(children) == 0 { + break + } + + err, r = d.RequestFileDetails(d.apikey, children) + if err != nil { + return err + } + } + + for k, v := range d.Fetched { + if v.FileURL == "" { + delete(d.Fetched, k) + } + } + + return nil +} + +func (d *Downloader) RequestFileDetails(key string, fileIDs []string) (error, *webapi.PublishedFileDetailsResponse) { + client := new(http.Client) + + req, err := http.NewRequest(http.MethodGet, "", nil) + if err != nil { + return err, nil + } + + req.URL.Scheme = "https" + req.URL.Host = "api.steampowered.com" + req.URL.Path = "/IPublishedFileService/GetDetails/v1" + + query := req.URL.Query() + query.Add("includechildren", "true") + query.Add("key", key) + + for i, v := range fileIDs { + query.Add(fmt.Sprintf("publishedfileids[%d]", i), v) + } + + req.URL.RawQuery = query.Encode() + + log.Printf("Requesting: %s - sleeping 5 seconds\n", req.URL) + time.Sleep(5 * time.Second) + + res, err := client.Do(req) + if err != nil { + return err, nil + } + + defer res.Body.Close() + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return err, nil + } + + r := new(webapi.PublishedFileDetailsResponse) + + err = json.Unmarshal(body, r) + if err != nil { + return err, nil + } + + return nil, r +} + +func (d *Downloader) DisplayFetched() { + keys := make(map[string]string, 0) + for k, v := range d.Fetched { + keys[v.Title] = k + } + + k2 := maps.Keys(keys) + slices.Sort(k2) + + for _, k := range k2 { + v := d.Fetched[keys[k]] + fmt.Printf("%s: %s (%s) [%s]\n", v.Title, v.FileURL, v.FileNamePrefixed(d.prefix), v.PreviewFileNamePrefixed(d.prefix)) + } +} + +func (d *Downloader) IsContentUpdateNeeded(v *manifest.Entry, v2 *manifest.Entry) bool { + return v.TimeUpdated != v2.TimeUpdated || v.TimeCreated != v2.TimeCreated || + v.HContentFile != v2.HContentFile || v.HContentFileSize != v2.HContentFileSize +} + +func (d *Downloader) IsPreviewUpdateNeeded(v *manifest.Entry, v2 *manifest.Entry) bool { + return v.TimeUpdated != v2.TimeUpdated || v.TimeCreated != v2.TimeCreated || + v.HContentPreview != v2.HContentPreview || v.HContentPreviewSize != v2.HContentPreviewSize +} + +func (d *Downloader) UpdateFiles(m *manifest.Manifest) { + for k, v := range d.Fetched { + contentUpdate := false + previewUpdate := false + contentSizeOnDisk := int64(0) + previewSizeOnDisk := int64(0) + + if v2, ok := m.Entries[k]; ok { + contentStat, err := os.Stat(v.FileNamePrefixed(d.prefix)) + if err == nil { + contentSizeOnDisk = contentStat.Size() + } else if errors.Is(err, os.ErrNotExist) { + contentUpdate = true + fmt.Fprintf(os.Stderr, "could not find file %s\n", v.FileNamePrefixed(d.prefix)) + } else { + contentUpdate = true + fmt.Fprintf(os.Stderr, "could not stat() %s: %s\n", v.FileNamePrefixed(d.prefix), err) + } + + previewStat, err := os.Stat(v.PreviewFileNamePrefixed(d.prefix)) + if err == nil { + previewSizeOnDisk = previewStat.Size() + } else if errors.Is(err, os.ErrNotExist) { + previewUpdate = true + fmt.Fprintf(os.Stderr, "could not find file %s\n", v.PreviewFileNamePrefixed(d.prefix)) + } else { + previewUpdate = true + fmt.Fprintf(os.Stderr, "could not stat() %s: %s\n", v.PreviewFileNamePrefixed(d.prefix), err) + } + + if !contentUpdate && !d.IsContentUpdateNeeded(&v.Entry, v2) && + fmt.Sprintf("%d", contentSizeOnDisk) == v.HContentFileSize { + log.Printf("%s.vpk (%s): already up to date\n", v.ID, v.Title) + } else { + contentUpdate = true + if fmt.Sprintf("%d", previewSizeOnDisk) != v.HContentPreviewSize { + log.Printf("%s.vpk (%s): expected size %s, have %d\n", v.ID, v.Title, v.HContentPreviewSize, previewSizeOnDisk) + } + } + + if !previewUpdate && !d.IsPreviewUpdateNeeded(&v.Entry, v2) && + fmt.Sprintf("%d", previewSizeOnDisk) == v.HContentPreviewSize { + log.Printf("%s.jpg (%s): already up to date\n", v.ID, v.Title) + } else { + previewUpdate = true + if fmt.Sprintf("%d", previewSizeOnDisk) != v.HContentPreviewSize { + log.Printf("%s.jpg (%s): expected size %s, have %d\n", v.ID, v.Title, v.HContentPreviewSize, previewSizeOnDisk) + } + } + } + + if previewUpdate { + err := d.DownloadFile(v.PreviewURL, v.PreviewFileNamePrefixed(d.prefix), v.HContentPreviewSize) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + continue + } + } + + if contentUpdate { + err := d.DownloadFile(v.FileURL, v.FileNamePrefixed(d.prefix), v.HContentFileSize) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + continue + } + } + + err := m.AddEntry(&v.Entry) + if err != nil { + fmt.Fprintf(os.Stderr, "Error adding entry for %s (id %s) to manifest\n", v.Title, v.ID) + fmt.Fprintf(os.Stderr, "%s\n", err) + } + } +} + +func (d *Downloader) DownloadFile(url string, filename string, filesize string) error { + out, err := os.OpenFile(filename, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0664) + defer out.Close() + if err != nil { + return fmt.Errorf("error creating file %s\n%w\n", filename, err) + } + + log.Printf("downloading %s to %s", url, filename) + + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("error downloading file %s\n%w\n", filename, err) + } + + n, err := io.Copy(out, resp.Body) + if err != nil { + return fmt.Errorf("error streaming file to %s\n%w\n", filename, err) + } + + if fmt.Sprintf("%d", n) != filesize { + return fmt.Errorf("expected %s bytes, only recieved %d bytes\n", filesize, n) + } + + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7b310e8 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/Neo-Desktop/Steamworks-Collection-Downloader + +go 1.18 + +require golang.org/x/exp v0.0.0-20221011201855-a3968a42eed6 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..627d89e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/exp v0.0.0-20221011201855-a3968a42eed6 h1:+hSdOdB7nHAFs+EDQXTvkJj7kUMugNAcE2x+BwxlVt4= +golang.org/x/exp v0.0.0-20221011201855-a3968a42eed6/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= diff --git a/main.go b/main.go new file mode 100644 index 0000000..d5f0cc9 --- /dev/null +++ b/main.go @@ -0,0 +1,122 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "strings" + + "github.com/Neo-Desktop/Steamworks-Collection-Downloader/downloader" + "github.com/Neo-Desktop/Steamworks-Collection-Downloader/manifest" +) + +var ( + APIKEY string + SEEDS []string + PATH string + MANIFESTJSON string +) + +const ( + REPOURL = "https://github.com/Neo-Desktop/Steamworks-Collection-Downloader" +) + +func init() { + flag.Usage = usage + + flagAPIKEY := flag.String("apikey", "", "Required: Steamworks Web API key") + flagHELP := flag.Bool("help", false, "Displays this help message") + flagPREFIX := flag.String("prefix", "."+string(os.PathSeparator)+"workshop", "path to the /addons/workshop folder") + flagMANIFEST := flag.String("manifest", "workshopmanifest.json", "filename to store the current state of downloaded files (will be stored in prefix)") + + envAPIKEY := os.Getenv("WSDL_APIKEY") + envPREFIX := os.Getenv("WSDL_PREFIX") + envSEEDS := os.Getenv("WSDL_SEEDS") + envMANIFEST := os.Getenv("WSDL_MANIFEST") + + flag.Parse() + + if flagAPIKEY != nil && *flagAPIKEY != "" { + APIKEY = *flagAPIKEY + } else if envAPIKEY != "" { + APIKEY = envAPIKEY + } else { + fmt.Fprintf(os.Stderr, "Please specify an APIKEY\n\n") + usage() + os.Exit(1) + } + + if flagPREFIX != nil && *flagPREFIX != "" { + PATH = *flagPREFIX + } else if envPREFIX != "" { + PATH = envPREFIX + } else { + fmt.Fprintf(os.Stderr, "Please specify a prefix\n\n") + usage() + os.Exit(1) + } + + if flagMANIFEST != nil && *flagMANIFEST != "" { + MANIFESTJSON = *flagMANIFEST + } else if envMANIFEST != "" { + MANIFESTJSON = envMANIFEST + } else { + fmt.Fprintf(os.Stderr, "Please specify a filename for the manifest\n\n") + usage() + os.Exit(1) + } + + if len(flag.Args()) != 0 { + SEEDS = flag.Args() + } else if envSEEDS != "" { + SEEDS = strings.Split(envSEEDS, " ") + } else { + fmt.Fprintf(os.Stderr, "Please specify one or more items to download (as command line arguments)\n\n") + usage() + os.Exit(1) + } + + if PATH[len(PATH)-1] != os.PathSeparator { + PATH = PATH + string(os.PathSeparator) + } + + if *flagHELP { + usage() + os.Exit(1) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, "Usage: %s [options] (-apikey ) [...]\n", os.Args[0]) + + flag.PrintDefaults() + + fmt.Fprintf(os.Stderr, " string\n Required: one or more Steamworks collection(s) or file ID(s) to download\n") + fmt.Fprintf(os.Stderr, "\nPlease visit %s for source code and more information\n\n", REPOURL) +} + +func main() { + d := new(downloader.Downloader) + m := new(manifest.Manifest) + + err := d.Start(APIKEY, SEEDS, PATH) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(2) + } + + d.DisplayFetched() + + err = m.Open(MANIFESTJSON, PATH) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(2) + } + + d.UpdateFiles(m) + + m.Close() + + log.Println("Completed!") +} diff --git a/manifest/entry.go b/manifest/entry.go new file mode 100644 index 0000000..9260bd7 --- /dev/null +++ b/manifest/entry.go @@ -0,0 +1,19 @@ +package manifest + +type Entry struct { + ID string + TimeCreated string + TimeUpdated string + HContentFile string + HContentFileSize string + HContentPreview string + HContentPreviewSize string +} + +func (w Entry) FileNamePrefixed(prefix string) string { + return prefix + w.ID + ".vpk" +} + +func (w Entry) PreviewFileNamePrefixed(prefix string) string { + return prefix + w.ID + ".jpg" +} diff --git a/manifest/manifest.go b/manifest/manifest.go new file mode 100644 index 0000000..dba6ef2 --- /dev/null +++ b/manifest/manifest.go @@ -0,0 +1,77 @@ +package manifest + +import ( + "encoding/json" + "io/ioutil" + "os" +) + +type Manifest struct { + filename string + prefix string + + Entries map[string]*Entry + + *os.File +} + +func (m *Manifest) Open(filename string, prefix string) (err error) { + filename = prefix + filename + m.File, err = os.OpenFile(filename, os.O_CREATE|os.O_RDWR, 0664) + if err != nil { + return err + } + + buffer, err := ioutil.ReadAll(m) + if err != nil { + return err + } + + err = m.Parse(buffer) + if err != nil { + return err + } + + return nil +} + +func (m *Manifest) Parse(manifest []byte) error { + m.Entries = make(map[string]*Entry) + + if len(manifest) == 0 { + return nil + } + + err := json.Unmarshal(manifest, &m.Entries) + if err != nil { + return err + } + + return nil +} + +func (m *Manifest) AddEntry(entry *Entry) error { + m.Entries[entry.ID] = entry + + _, err := m.Seek(0, 0) + if err != nil { + return err + } + + err = m.Truncate(0) + if err != nil { + return err + } + + b, err := json.Marshal(m.Entries) + if err != nil { + return err + } + + _, err = m.Write(b) + if err != nil { + return err + } + + return nil +} diff --git a/webapi/publishedFileDetailsResponse.go b/webapi/publishedFileDetailsResponse.go new file mode 100644 index 0000000..54bf812 --- /dev/null +++ b/webapi/publishedFileDetailsResponse.go @@ -0,0 +1,67 @@ +package webapi + +type PublishedFileDetailsResponse struct { + Response struct { + PublishedFileDetails []struct { + Result int `json:"result"` + PublishedFileID string `json:"publishedfileid"` + Creator string `json:"creator"` + CreatorAppid int `json:"creator_appid"` + ConsumerAppid int `json:"consumer_appid"` + ConsumerShortcutID int `json:"consumer_shortcutid"` + Filename string `json:"filename"` + FileSize string `json:"file_size"` + PreviewFileSize string `json:"preview_file_size"` + FileURL string `json:"file_url"` + PreviewURL string `json:"preview_url"` + URL string `json:"url"` + HContentFile string `json:"hcontent_file"` + HContentPreview string `json:"hcontent_preview"` + Title string `json:"title"` + FileDescription string `json:"file_description"` + TimeCreated int `json:"time_created"` + TimeUpdated int `json:"time_updated"` + Visibility int `json:"visibility"` + Flags int `json:"flags"` + WorkshopFile bool `json:"workshop_file"` + WorkshopAccepted bool `json:"workshop_accepted"` + ShowSubscribeAll bool `json:"show_subscribe_all"` + NumCommentsPublic int `json:"num_comments_public"` + Banned bool `json:"banned"` + BanReason string `json:"ban_reason"` + Banner string `json:"banner"` + CanBeDeleted bool `json:"can_be_deleted"` + AppName string `json:"app_name"` + FileType int `json:"file_type"` + CanSubscribe bool `json:"can_subscribe"` + Subscriptions int `json:"subscriptions"` + Favorited int `json:"favorited"` + Followers int `json:"followers"` + LifetimeSubscriptions int `json:"lifetime_subscriptions"` + LifetimeFavorited int `json:"lifetime_favorited"` + LifetimeFollowers int `json:"lifetime_followers"` + LifetimePlaytime string `json:"lifetime_playtime"` + LifetimePlaytimeSessions string `json:"lifetime_playtime_sessions"` + Views int `json:"views"` + ImageWidth int `json:"image_width,omitempty"` + ImageHeight int `json:"image_height,omitempty"` + NumChildren int `json:"num_children"` + NumReports int `json:"num_reports"` + Tags []struct { + Tag string `json:"tag"` + DisplayName string `json:"display_name"` + } `json:"tags"` + Children []struct { + PublishedFileID string `json:"publishedfileid"` + SortOrder int `json:"sortorder"` + FileType int `json:"file_type"` + } `json:"children,omitempty"` + Language int `json:"language"` + MaybeInappropriateSex bool `json:"maybe_inappropriate_sex"` + MaybeInappropriateViolence bool `json:"maybe_inappropriate_violence"` + RevisionChangeNumber string `json:"revision_change_number"` + Revision int `json:"revision"` + BanTextCheckResult int `json:"ban_text_check_result"` + } `json:"publishedfiledetails"` + } `json:"response"` +}