├── .gitignore ├── LICENSE ├── README.md ├── anilist ├── anilist.go ├── animation_helpers.go ├── gql_queries.go └── gql_types.go ├── anilist_app.go ├── anilist_app_test.go ├── anilist_utils.go ├── config.go ├── dialog ├── commons.go ├── list_select.go ├── ok_dialog.go └── stuff_loader.go ├── fuzzyselect.go ├── go.mod ├── go.sum ├── gobuildall.ps1 ├── gobuildall.sh ├── main.go ├── mal ├── anime.go ├── animelist.go ├── animeparser.go ├── mal.go └── utils.go ├── mal_app.go ├── mal_cache.go ├── nyaa_cui.go ├── nyaa_scraper └── nyaa.go ├── oauth2 └── implicit_grant.go ├── search_cui.go ├── templates.go └── utils.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Go template 3 | # Binaries for programs and plugins 4 | *.exe 5 | *.dll 6 | *.so 7 | *.dylib 8 | 9 | # Test binary, build with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 16 | .glide/ 17 | 18 | # IntelliJ IDEA project 19 | mal.iml 20 | .idea 21 | 22 | # User files 23 | data 24 | 25 | # build directory 26 | build 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 aQaTL 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Another MAL/AniList client 2 | 3 | This time with CLI 4 | 5 | - [Dependencies](#dependencies) 6 | - [Quick start](#quick-start) 7 | - [Commands](#commands) - usage of some commands 8 | - [Examples](#examples) usage 9 | 10 | ## Dependencies 11 | 12 | ### For Linux 13 | 14 | In order to have `mal copy` command working, you need to have either `xsel` or `xclip` installed. 15 | 16 | ## Quick start 17 | 18 | If you have a working Go environment, you can download the app via `go get -u github.com/aqatl/mal`. 19 | Otherwise, download binaries from the [release](https://github.com/aQaTL/MAL/releases) page. 20 | 21 | Config files location: 22 | 23 | 1. Linux: `$XDG_CONFIG_DIR/mal` (`$HOME/.config/mal` if `$XDG_CONFIG_DIR` env var is not set) . 24 | 2. Windows: `%AppData%\mal` 25 | 3. MacOS: `$HOME/Library/Application Support/mal` 26 | 27 | ### AniList mode 28 | 29 | AniList mode is used by default. All you need to do to configure the app is to simply execute the program. 30 | It'll open AniList login page in your browser. Log in and authorize the app. And that's it - mal will cache 31 | the received token on your disk and use it to authenticate your requests. 32 | 33 | Run mal with `-r` flag to refresh cached lists. 34 | 35 | ### MyAnimeList mode 36 | 37 | **Notice:** MAL API is shut down, currently it is not possible to access it. 38 | 39 | To switch between AniList and MyAnimeList mode use the `s` command (e.g. `mal s`). 40 | 41 | First, you need to give the app your credentials - username and password. To do that, execute 42 | `mal --prompt-credentials --verify --save-password`. If everything went good, you should see 43 | a list of 10 entries. 44 | 45 | ### Default behavior 46 | 47 | The base command for everything is `mal`, which by default displays 10 last updated entries 48 | from your MAL. You can change the displayed list through some flags: 49 | 50 | ``` 51 | --max value visible entries threshold (default: 0) 52 | --a display all entries; same as max -1 53 | --status value display entries only with given status [watching|planning|completed|repeating|paused|dropped] 54 | --sort value display entries sorted by: [last-updated|title|episodes|score] 55 | --reversed reversed list order 56 | ``` 57 | 58 | It's also good to run the app with `-r` (or `--refresh`) to update the cached list. Mind that there is not refresh interval so you have to refresh manually. 59 | 60 | List of all commands and possible flags is available via `mal --help`. 61 | 62 | ### Commands 63 | 64 | All actions are done by variety of commands. They are in the following form: 65 | `mal [global flags] command [command flags] [command arguments]` 66 | 67 | Commands listed in `help` are divides into categories: 68 | 69 | * **Update** command changes entry data and sends the updated version to your account 70 | * **Action** command performs action that uses the entry data like printing it to the console 71 | * **Config** command manipulates on the app configuration file (look at `mal cfg --help` for details) 72 | 73 | You can always see the details of the specific command via `help` like this: 74 | `mal --help` 75 | 76 | #### Select entry to work with 77 | 78 | Commands that use entry data need to know which entry you want to use. And there's a thing 79 | called "selected entry". To select an entry, use the `mal sel` command. And here's a usage 80 | of that command (` mal sel --help`): 81 | 82 | ``` 83 | NAME: 84 | mal sel - Select an entry 85 | 86 | USAGE: 87 | mal sel [entry title] 88 | 89 | CATEGORY: 90 | Config 91 | ``` 92 | 93 | For example, to select "Naruto", type `mal sel naruto` (case insensitive). 94 | If `sel` is given no arguments, it will open a fuzzy search cui (console gui). 95 | 96 | #### Update entry 97 | 98 | For now, you can update your entry with the following commands: 99 | 100 | ``` 101 | eps, episodes Set the watched episodes value. If n not specified, the number will be increased by one 102 | score Set your rating for selected entry 103 | status Set your status for selected entry 104 | cmpl Alias for 'mal status completed' 105 | delete, del Delete entry 106 | ``` 107 | 108 | ##### `mal eps` command 109 | 110 | ``` 111 | NAME: 112 | mal eps - Set the watched episodes value. If n not specified, the number will be increased by one 113 | 114 | USAGE: 115 | mal eps 116 | 117 | CATEGORY: 118 | Update 119 | ``` 120 | 121 | There's an option to have mal automatically turn the entry status to completed after updating 122 | the watched episodes value. To do that, use the `status-auto-update` config command. 123 | 124 | ``` 125 | NAME: 126 | mal cfg status-auto-update - Allows entry to be automatically set to completed when number of all episodes is reached or exceeded 127 | 128 | USAGE: 129 | mal cfg status-auto-update [off|normal|after-threshold] 130 | ``` 131 | 132 | As you can see, there are 2 modes of auto-update: normal and after-threshold. 133 | 134 | The first behaves as you would expect -> the status is changes when entry has 12 episodes 135 | and you hit the 12 watched episodes. 136 | 137 | As for the `after-threshold`, the status will change after you exceed the number of 138 | episodes. For example: when entry has 12 episodes and you hit 13 -> status is changed to 139 | completed and your watched entries value is changed back to 12. 140 | 141 | ##### `mal score` command 142 | 143 | ``` 144 | NAME: 145 | mal score - Set your rating for selected entry 146 | 147 | USAGE: 148 | mal score <0-10> 149 | 150 | CATEGORY: 151 | Update 152 | ``` 153 | 154 | ##### `mal status` command 155 | 156 | ``` 157 | NAME: 158 | mal status - Set your status for selected entry 159 | 160 | USAGE: 161 | mal status [watching|planning|completed|dropped|paused|repeating] 162 | 163 | CATEGORY: 164 | Update 165 | ``` 166 | 167 | There is also `cmpl` command that is an alias for `status completed`. 168 | 169 | Unfortunately, some commands may slightly differ between MyAnimeList and AniList mode and some may not 170 | be present in both. 171 | 172 | ## Examples 173 | 174 | A few examples of how I use this program. 175 | 176 | Remember that everything is in `--help` :) 177 | 178 | ### Everyday usage 179 | 180 | Okay, so when I add a new anime to my list, I run `mal -r` to update the cache. Then, if I 181 | want to watch it, I select it with `mal sel [name]`. Then I go to the web browser to find a 182 | website where I can watch it. If the name is long, I copy the title with `mal copy title`. 183 | To not forget the website and make it a little bit more convenient for me in the future, I 184 | copy the website's link and bind it to the selected anime with `mal web [website url]`. 185 | 186 | Now, when I want to watch it, I can just type `mal web` and it will open saved url in the 187 | web browser (you can configure which browser to use). When I finish an episode I type 188 | `mal eps` to update watched episodes and that's it. There's an option to automatically set 189 | the status to "completed", so I don't have to do anything more. 190 | 191 | Oh, and usually I also rate the show by `mal score [number from 0 to 10]`. 192 | 193 | ### Showing all entries from plan to watch list 194 | 195 | Useful when you want to choose what to watch next. 196 | 197 | `mal --status plantowatch --max -1` 198 | 199 | The `--max -1` flag tells the program not to limit the displayed list length. 200 | 201 | ### Checking highest ranked (by you) shows 202 | 203 | `mal --status all --sort score` 204 | 205 | Again, you can add `--max -1` flag to turn off the list length limit. 206 | 207 | ### Showing your account stats 208 | 209 | `mal stats` 210 | 211 | ### Checking entry details 212 | 213 | `mal details` 214 | 215 | `mal related` 216 | 217 | `mal music` 218 | -------------------------------------------------------------------------------- /anilist/anilist.go: -------------------------------------------------------------------------------- 1 | package anilist 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "strings" 10 | 11 | "github.com/aqatl/mal/oauth2" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | const ApiEndpoint = "https://graphql.anilist.co" 16 | const ALDomain = "https://anilist.co" 17 | 18 | var InvalidToken = errors.New("Invalid token") 19 | 20 | // TODO downloading only given list like watching/completed 21 | func QueryUserLists(userId int, scoreFormat ScoreFormat, token oauth2.OAuthToken) ([]MediaListGroup, error) { 22 | vars := make(map[string]interface{}) 23 | vars["userID"] = userId 24 | vars["scoreFormat"] = scoreFormat 25 | 26 | resp := struct { 27 | MediaListCollection `json:"MediaListCollection"` 28 | }{MediaListCollection{}} 29 | if err := gqlErrorsHandler(graphQLRequestParsed(queryUserAnimeList, vars, token, &resp)); err != nil { 30 | return nil, err 31 | } 32 | return resp.Lists, nil 33 | } 34 | 35 | func QueryAuthenticatedUser(user *User, token oauth2.OAuthToken) error { 36 | viewer := &struct { 37 | *User `json:"Viewer"` 38 | }{user} 39 | return gqlErrorsHandler(graphQLRequestParsed(queryAuthenticatedUser, nil, token, viewer)) 40 | } 41 | 42 | func SaveMediaListEntry(entry *MediaListEntry, token oauth2.OAuthToken) error { 43 | vars := make(map[string]interface{}) 44 | vars["listId"] = entry.ListId 45 | vars["mediaId"] = entry.Id 46 | vars["status"] = string(entry.Status) 47 | vars["progress"] = entry.Progress 48 | vars["score"] = entry.Score 49 | entryData := &struct { 50 | *MediaListEntry `json:"SaveMediaListEntry"` 51 | }{entry} 52 | return gqlErrorsHandler(graphQLRequestParsed(saveMediaListEntry, vars, token, entryData)) 53 | } 54 | 55 | func AddMediaListEntry(id int, status MediaListStatus, token oauth2.OAuthToken) ( 56 | MediaListEntry, error, 57 | ) { 58 | vars := make(map[string]interface{}) 59 | vars["mediaId"] = id 60 | vars["status"] = string(status) 61 | entryData := &struct { 62 | MediaListEntry `json:"SaveMediaListEntry"` 63 | }{} 64 | err := gqlErrorsHandler(graphQLRequestParsed(addMediaListEntry, vars, token, entryData)) 65 | return entryData.MediaListEntry, err 66 | } 67 | 68 | func QueryAiringSchedule(mediaId, episode int, token oauth2.OAuthToken) (AiringSchedule, error) { 69 | vars := make(map[string]interface{}) 70 | vars["mediaId"] = mediaId 71 | vars["episode"] = episode 72 | data := &struct { 73 | AiringSchedule `json:"AiringSchedule"` 74 | }{AiringSchedule{}} 75 | err := gqlErrorsHandler(graphQLRequestParsed(queryAiringSchedule, vars, token, data)) 76 | return data.AiringSchedule, err 77 | } 78 | 79 | func QueryAiringNotification(markRead bool, token oauth2.OAuthToken) (AiringNotification, error) { 80 | vars := make(map[string]interface{}) 81 | vars["resetNotificationCount"] = markRead 82 | data := new(struct { 83 | AiringNotification `json:"Notification"` 84 | }) 85 | err := gqlErrorsHandler(graphQLRequestParsed(queryAiringNotification, vars, token, data)) 86 | return data.AiringNotification, err 87 | } 88 | 89 | func QueryAiringNotifications(page, perPage int, markRead bool, token oauth2.OAuthToken) ( 90 | []AiringNotification, error, 91 | ) { 92 | vars := make(map[string]interface{}) 93 | vars["page"] = page 94 | vars["perPage"] = perPage 95 | vars["resetNotificationCount"] = markRead 96 | data := new(struct { 97 | Page struct { 98 | Notifications []AiringNotification `json:"notifications"` 99 | } `json:"Page"` 100 | }) 101 | err := gqlErrorsHandler(graphQLRequestParsed(queryAiringNotifications, vars, token, data)) 102 | return data.Page.Notifications, err 103 | } 104 | 105 | func DeleteMediaListEntry(entry *MediaListEntry, token oauth2.OAuthToken) error { 106 | vars := make(map[string]interface{}) 107 | vars["id"] = entry.ListId 108 | data := new(struct { 109 | D struct { 110 | Deleted bool `json:"deleted"` 111 | } `json:"DeleteMediaListEntry"` 112 | }) 113 | err := gqlErrorsHandler(graphQLRequestParsed(deleteMediaListEntry, vars, token, data)) 114 | if err != nil { 115 | return err 116 | } 117 | if !data.D.Deleted { 118 | return fmt.Errorf("deletion unsuccessfull") 119 | } 120 | return nil 121 | } 122 | 123 | func Search(query string, page, perPage int, mtype MediaType, token oauth2.OAuthToken) ([]MediaFull, error) { 124 | vars := make(map[string]interface{}) 125 | vars["page"] = page 126 | vars["perPage"] = perPage 127 | vars["search"] = query 128 | vars["type"] = mtype 129 | 130 | data := new(struct { 131 | Page struct { 132 | Media []MediaFull `json:"media"` 133 | } `json:"Page"` 134 | }) 135 | err := gqlErrorsHandler(graphQLRequestParsed(queryMedia, vars, token, data)) 136 | return data.Page.Media, err 137 | } 138 | 139 | func gqlErrorsHandler(gqlErrs []GqlError, err error) error { 140 | if err != nil { 141 | return err 142 | } 143 | for _, gqlErr := range gqlErrs { 144 | if gqlErr.Message == "Invalid token" { 145 | return InvalidToken 146 | } 147 | } 148 | if len(gqlErrs) > 0 { 149 | locations := strings.Builder{} 150 | for _, loc := range gqlErrs[0].Locations { 151 | locations.WriteString(fmt.Sprintf("Line %d column %d\n", loc.Line, loc.Column)) 152 | } 153 | return fmt.Errorf("GraphQl Error (%d): %s\n%s", 154 | gqlErrs[0].Status, gqlErrs[0].Message, locations.String()) 155 | } 156 | return nil 157 | } 158 | 159 | func printGqlErrs(gqlErrs []GqlError) { 160 | for _, gqlErr := range gqlErrs { 161 | fmt.Printf("GraphQl Error (%d): %s\n", gqlErr.Status, gqlErr.Message) 162 | for _, loc := range gqlErr.Locations { 163 | fmt.Printf("Line %d column %d\n", loc.Line, loc.Column) 164 | } 165 | } 166 | } 167 | 168 | func graphQLRequestParsed(query string, vars map[string]interface{}, t oauth2.OAuthToken, 169 | x interface{}) ([]GqlError, error) { 170 | resp, err := graphQLRequest(query, vars, t) 171 | if resp != nil { 172 | defer resp.Body.Close() 173 | } 174 | if err != nil { 175 | return nil, err 176 | } 177 | type responseData struct { 178 | Data interface{} 179 | Errors []GqlError 180 | } 181 | respData := &responseData{Data: x} 182 | 183 | if err := json.NewDecoder(resp.Body).Decode(respData); err != nil { 184 | return nil, err 185 | } 186 | if len(respData.Errors) > 0 { 187 | // TODO include all error fields 188 | // TODO better error handling -> maybe typedef error array as QueryErrors? 189 | return respData.Errors, nil 190 | } 191 | return nil, nil 192 | } 193 | 194 | func graphQLRequestString(query string, vars map[string]interface{}, t oauth2.OAuthToken) ( 195 | string, error, 196 | ) { 197 | resp, err := graphQLRequest(query, vars, t) 198 | if resp != nil { 199 | defer resp.Body.Close() 200 | } 201 | if err != nil { 202 | return "", err 203 | } 204 | data, err := ioutil.ReadAll(resp.Body) 205 | return string(data), err 206 | } 207 | 208 | func graphQLRequest(query string, vars map[string]interface{}, t oauth2.OAuthToken) ( 209 | *http.Response, error, 210 | ) { 211 | reqBody := bytes.Buffer{} 212 | err := json.NewEncoder(&reqBody).Encode(struct { 213 | Query string `json:"query"` 214 | Variables map[string]interface{} `json:"variables"` 215 | }{query, vars}) 216 | if err != nil { 217 | return nil, err 218 | } 219 | 220 | req, err := http.NewRequest(http.MethodPost, ApiEndpoint, &reqBody) 221 | if err != nil { 222 | return nil, err 223 | } 224 | req.Header.Set("Content-Type", "application/json") 225 | req.Header.Set("Accept", "application/json") 226 | req.Header.Set("Authorization", "Bearer "+t.Token) 227 | 228 | return http.DefaultClient.Do(req) 229 | } 230 | 231 | func ParseStatus(status string) MediaListStatus { 232 | switch strings.ToLower(status) { 233 | case "watching", "current": 234 | return Current 235 | case "planning", "plantowatch": 236 | return Planning 237 | case "completed": 238 | return Completed 239 | case "dropped": 240 | return Dropped 241 | case "paused", "onhold": 242 | return Paused 243 | case "repeating": 244 | return Repeating 245 | default: 246 | return All 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /anilist/animation_helpers.go: -------------------------------------------------------------------------------- 1 | package anilist 2 | 3 | import ( 4 | "github.com/aqatl/mal/oauth2" 5 | "github.com/aqatl/cliwait" 6 | ) 7 | 8 | func QueryUserListsWaitAnimation(userId int, scoreFormat ScoreFormat, token oauth2.OAuthToken) ([]MediaListGroup, error) { 9 | var mlg []MediaListGroup 10 | var err error 11 | cliwait.DoFuncWithWaitAnimation("Queyring user list", func() { 12 | mlg, err = QueryUserLists(userId, scoreFormat, token) 13 | }) 14 | return mlg, err 15 | } 16 | 17 | func QueryAuthenticatedUserWaitAnimation(user *User, token oauth2.OAuthToken) error { 18 | var err error 19 | cliwait.DoFuncWithWaitAnimation("Saving entry", func() { 20 | err = QueryAuthenticatedUser(user, token) 21 | }) 22 | return err 23 | } 24 | 25 | func SaveMediaListEntryWaitAnimation(entry *MediaListEntry, token oauth2.OAuthToken) error { 26 | var err error 27 | cliwait.DoFuncWithWaitAnimation("Saving entry", func() { 28 | err = SaveMediaListEntry(entry, token) 29 | }) 30 | return err 31 | } 32 | 33 | func QueryAiringScheduleWaitAnimation(mediaId, episode int, token oauth2.OAuthToken) ( 34 | AiringSchedule, error, 35 | ) { 36 | var as AiringSchedule 37 | var err error 38 | cliwait.DoFuncWithWaitAnimation("Querying airing schedule", func() { 39 | as, err = QueryAiringSchedule(mediaId, episode, token) 40 | }) 41 | return as, err 42 | } 43 | 44 | func QueryAiringNotificationsWaitAnimation(page, perPage int, markRead bool, token oauth2.OAuthToken) ( 45 | []AiringNotification, error, 46 | ) { 47 | var n []AiringNotification 48 | var err error 49 | cliwait.DoFuncWithWaitAnimation("Querying notification", func() { 50 | n, err = QueryAiringNotifications(page, perPage, markRead, token) 51 | }) 52 | return n, err 53 | } 54 | -------------------------------------------------------------------------------- /anilist/gql_queries.go: -------------------------------------------------------------------------------- 1 | package anilist 2 | 3 | var queryUserAnimeList = ` 4 | query UserList ($userID: Int, $scoreFormat: ScoreFormat) { 5 | MediaListCollection (userId: $userID, type: ANIME) { 6 | lists { 7 | entries { 8 | id 9 | status 10 | score(format: $scoreFormat) 11 | progress 12 | repeat 13 | updatedAt 14 | media { 15 | id 16 | idMal 17 | title { 18 | romaji 19 | english 20 | native 21 | userPreferred 22 | } 23 | type 24 | format 25 | status 26 | season 27 | episodes 28 | duration 29 | synonyms 30 | } 31 | } 32 | name 33 | isCustomList 34 | isSplitCompletedList 35 | status 36 | } 37 | } 38 | } 39 | ` 40 | 41 | var queryUserAnimeListFullDetails = ` 42 | query UserList ($userID: Int) { 43 | MediaListCollection (userId: $userID, type: ANIME) { 44 | lists { 45 | entries { 46 | id 47 | status 48 | score(format: POINT_10) 49 | progress 50 | updatedAt 51 | media { 52 | ` + mediaFull + ` 53 | } 54 | } 55 | name 56 | isCustomList 57 | isSplitCompletedList 58 | status 59 | } 60 | } 61 | } 62 | fragment FuzzyDateFields on FuzzyDate { 63 | year 64 | month 65 | day 66 | } 67 | ` 68 | 69 | var queryAuthenticatedUser = ` 70 | query { 71 | Viewer { 72 | id 73 | name 74 | about 75 | bannerImage 76 | stats { 77 | watchedTime 78 | } 79 | unreadNotificationCount 80 | siteUrl 81 | donatorTier 82 | moderatorStatus 83 | updatedAt 84 | mediaListOptions { 85 | scoreFormat 86 | } 87 | } 88 | } 89 | ` 90 | 91 | var saveMediaListEntry = ` 92 | mutation ($listId: Int, $mediaId: Int, $status: MediaListStatus, $progress: Int, $score: Float) { 93 | SaveMediaListEntry (id: $listId, mediaId: $mediaId, status: $status, progress: $progress, score: $score) { 94 | id 95 | status 96 | progress 97 | score 98 | updatedAt 99 | } 100 | } 101 | ` 102 | 103 | var addMediaListEntry = ` 104 | mutation ($mediaId: Int, $status: MediaListStatus) { 105 | SaveMediaListEntry (mediaId: $mediaId, status: $status) { 106 | id 107 | status 108 | score(format: POINT_10) 109 | progress 110 | repeat 111 | updatedAt 112 | media { 113 | id 114 | idMal 115 | title { 116 | romaji 117 | english 118 | native 119 | userPreferred 120 | } 121 | type 122 | format 123 | status 124 | season 125 | episodes 126 | duration 127 | synonyms 128 | } 129 | } 130 | } 131 | ` 132 | 133 | var queryAiringSchedule = ` 134 | query ($mediaId: Int, $episode: Int) { 135 | AiringSchedule(mediaId: $mediaId, episode: $episode) { 136 | id 137 | airingAt 138 | timeUntilAiring 139 | episode 140 | mediaId 141 | } 142 | } 143 | ` 144 | 145 | var queryAiringNotification = ` 146 | query ($resetNotificationCount: Boolean) { 147 | Notification(type: AIRING, resetNotificationCount: $resetNotificationCount) { 148 | ... on AiringNotification { 149 | id 150 | type 151 | animeId 152 | episode 153 | contexts 154 | createdAt 155 | media { 156 | title { 157 | romaji 158 | english 159 | native 160 | userPreferred 161 | } 162 | } 163 | } 164 | } 165 | }` 166 | 167 | var queryAiringNotifications = ` 168 | query ($page: Int, $perPage: Int, $resetNotificationCount: Boolean) { 169 | Page(page: $page, perPage: $perPage) { 170 | notifications(type_in: [AIRING], resetNotificationCount: $resetNotificationCount) { 171 | ... on AiringNotification { 172 | id 173 | animeId 174 | episode 175 | contexts 176 | createdAt 177 | media { 178 | title { 179 | romaji 180 | english 181 | native 182 | userPreferred 183 | } 184 | } 185 | } 186 | } 187 | } 188 | } 189 | ` 190 | 191 | var deleteMediaListEntry = ` 192 | mutation ($id: Int) { 193 | DeleteMediaListEntry(id: $id) { 194 | deleted 195 | } 196 | } 197 | ` 198 | 199 | var mediaFull = ` 200 | id 201 | idMal 202 | title { 203 | romaji 204 | english 205 | native 206 | userPreferred 207 | } 208 | type 209 | format 210 | status 211 | description 212 | startDate { 213 | ...FuzzyDateFields 214 | } 215 | endDate { 216 | ...FuzzyDateFields 217 | } 218 | season 219 | episodes 220 | duration 221 | chapters 222 | volumes 223 | countryOfOrigin 224 | isLicensed 225 | source 226 | hashtag 227 | trailer { 228 | id 229 | site 230 | } 231 | updatedAt 232 | coverImage { 233 | large 234 | medium 235 | } 236 | bannerImage 237 | genres 238 | synonyms 239 | averageScore 240 | meanScore 241 | popularity 242 | trending 243 | tags { 244 | id 245 | name 246 | description 247 | category 248 | rank 249 | isGeneralSpoiler 250 | isMediaSpoiler 251 | isAdult 252 | } 253 | isFavourite 254 | isAdult 255 | nextAiringEpisode { 256 | id 257 | airingAt 258 | timeUntilAiring 259 | episode 260 | } 261 | siteUrl 262 | ` 263 | 264 | var queryMedia = ` 265 | query ($page: Int, $perPage: Int, $search: String, $type: MediaType) { 266 | Page(page: $page, perPage: $perPage) { 267 | media(search: $search, type: $type) { 268 | ` + mediaFull + ` 269 | } 270 | } 271 | } 272 | fragment FuzzyDateFields on FuzzyDate { 273 | year 274 | month 275 | day 276 | } 277 | ` 278 | -------------------------------------------------------------------------------- /anilist/gql_types.go: -------------------------------------------------------------------------------- 1 | package anilist 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type User struct { 8 | Id int `json:"id"` 9 | Name string `json:"name"` 10 | About string `json:"about"` 11 | BannerImage string `json:"bannerImage"` 12 | Stats UserStats `json:"stats"` 13 | UnreadNotificationCount int `json:"unreadNotificationCount"` 14 | SiteUrl string `json:"siteUrl"` 15 | DonatorTier int `json:"donatorTier"` 16 | ModeratorStatus string `json:"moderatorStatus"` 17 | UpdatedAt int `json:"updatedAt"` 18 | MediaListOptions MediaListOptions `json:"mediaListOptions"` 19 | } 20 | 21 | type UserStats struct { 22 | WatchedTime int `json:"watchedTime"` 23 | } 24 | 25 | type MediaListOptions struct { 26 | ScoreFormat ScoreFormat `json:"scoreFormat"` 27 | } 28 | 29 | type ScoreFormat string 30 | 31 | const ( 32 | Point100 ScoreFormat = "POINT_100" 33 | Point10Decimal ScoreFormat = "POINT_10_DECIMAL" 34 | Point10 ScoreFormat = "POINT_10" 35 | Point5 ScoreFormat = "POINT_5" 36 | Point3 ScoreFormat = "POINT_3" 37 | ) 38 | 39 | type MediaListCollection struct { 40 | Lists []MediaListGroup `json:"lists"` 41 | } 42 | 43 | type MediaListGroup struct { 44 | Entries []MediaListEntry `json:"entries"` 45 | Name string `json:"name"` 46 | IsCustomList bool `json:"isCustomList"` 47 | IsSplitCompletedList bool `json:"isSplitCompletedList"` 48 | Status MediaListStatus `json:"status"` 49 | } 50 | 51 | type MediaListEntry struct { 52 | ListId int `json:"id"` 53 | Status MediaListStatus `json:"status"` 54 | Score float32 `json:"score"` 55 | Progress int `json:"progress"` 56 | Repeat int `json:"repeat"` 57 | UpdatedAt int `json:"updatedAt"` 58 | 59 | MediaDeficient `json:"media"` 60 | } 61 | 62 | type MediaDeficient struct { 63 | Id int `json:"id"` 64 | IdMal int `json:"idMal"` 65 | Title MediaTitle `json:"title"` 66 | Type string `json:"type"` 67 | Format string `json:"format"` 68 | Status string `json:"status"` 69 | Season string `json:"season"` 70 | Episodes int `json:"episodes"` 71 | Duration int `json:"duration"` 72 | Synonyms []string `json:"synonyms"` 73 | } 74 | 75 | type MediaFull struct { 76 | Id int `json:"id"` 77 | IdMal int `json:"idMal"` 78 | Title MediaTitle `json:"title"` 79 | Type MediaType `json:"type"` 80 | Format string `json:"format"` 81 | Status string `json:"status"` 82 | Description string `json:"description"` 83 | StartDate FuzzyDate `json:"startDate"` 84 | EndDate FuzzyDate `json:"endDate"` 85 | Season string `json:"season"` 86 | Episodes int `json:"episodes"` 87 | Duration int `json:"duration"` 88 | Chapters int `json:"chapters"` 89 | Volumes int `json:"volumes"` 90 | CountryOfOrigin string `json:"countryOfOrigin"` 91 | IsLicensed bool `json:"isLicensed"` 92 | Source string `json:"source"` 93 | HashTag string `json:"hashtag"` 94 | Trailer MediaTrailer `json:"trailer"` 95 | UpdatedAt int `json:"updatedAt"` 96 | CoverImage MediaCoverImage `json:"coverImage"` 97 | BannerImage string `json:"bannerImage"` 98 | Genres []string `json:"genres"` 99 | Synonyms []string `json:"synonyms"` 100 | AverageScore int `json:"averageScore"` 101 | MeanScore int `json:"meanScore"` 102 | Popularity int `json:"popularity"` 103 | Trending int `json:"trending"` 104 | Tags []MediaTag `json:"tags"` 105 | IsFavourite bool `json:"isFavourite"` 106 | IsAdult bool `json:"isAdult"` 107 | NextAiringEpisode AiringSchedule `json:"nextAiringEpisode"` 108 | SiteUrl string `json:"siteUrl"` 109 | } 110 | 111 | type MediaTitle struct { 112 | Romaji string `json:"romaji"` 113 | English string `json:"english"` 114 | Native string `json:"native"` 115 | UserPreferred string `json:"userPreferred"` 116 | } 117 | 118 | type MediaType string 119 | 120 | const ( 121 | Anime = MediaType("ANIME") 122 | Mange = MediaType("MANGA") 123 | ) 124 | 125 | type FuzzyDate struct { 126 | Year int `json:"year"` 127 | Month int `json:"month"` 128 | Day int `json:"day"` 129 | } 130 | 131 | type AiringSchedule struct { 132 | Id int `json:"id"` 133 | AiringAt int `json:"airingAt"` 134 | TimeUntilAiring int `json:"timeUntilAiring"` 135 | Episode int `json:"episode"` 136 | } 137 | 138 | type MediaTrailer struct { 139 | Id string `json:"id"` 140 | Site string `json:"site"` 141 | } 142 | 143 | type MediaCoverImage struct { 144 | Large string `json:"large"` 145 | Medium string `json:"medium"` 146 | } 147 | 148 | type MediaTag struct { 149 | Id int `json:"id"` 150 | Name string `json:"name"` 151 | Description string `json:"description"` 152 | Category string `json:"category"` 153 | Rank int `json:"rank"` 154 | IsGeneralSpoiler bool `json:"isGeneralSpoiler"` 155 | IsMediaSpoiler bool `json:"isMediaSpoiler"` 156 | IsAdult bool `json:"isAdult"` 157 | } 158 | 159 | type GqlError struct { 160 | Message string `json:"message"` 161 | Status int `json:"status"` 162 | Locations []Location `json:"locations"` 163 | } 164 | 165 | type Location struct { 166 | Line int `json:"line"` 167 | Column int `json:"column"` 168 | } 169 | 170 | type MediaListStatus string 171 | 172 | const ( 173 | All MediaListStatus = "" 174 | Current MediaListStatus = "CURRENT" 175 | Planning MediaListStatus = "PLANNING" 176 | Completed MediaListStatus = "COMPLETED" 177 | Dropped MediaListStatus = "DROPPED" 178 | Paused MediaListStatus = "PAUSED" 179 | Repeating MediaListStatus = "REPEATING" 180 | ) 181 | 182 | func (status MediaListStatus) String() string { 183 | if status == All { 184 | return "" 185 | } else if status == Current { 186 | return "Watching" 187 | } else { 188 | return string(status[0]) + strings.ToLower(string(status[1:])) 189 | } 190 | } 191 | 192 | type AiringNotification struct { 193 | Id int `json:"id"` 194 | Type string `json:"type"` 195 | AnimeId int `json:"animeId"` 196 | Episode int `json:"episode"` 197 | Contexts []string `json:"contexts"` 198 | CreatedAt int `json:"createdAt"` 199 | Title struct { 200 | MediaTitle `json:"title"` 201 | } `json:"media"` 202 | } 203 | -------------------------------------------------------------------------------- /anilist_app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "math/rand" 7 | "net/url" 8 | "sort" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/aqatl/mal/anilist" 14 | "github.com/aqatl/mal/mal" 15 | "github.com/atotto/clipboard" 16 | "github.com/fatih/color" 17 | "github.com/hako/durafmt" 18 | "github.com/skratchdot/open-golang/open" 19 | "github.com/urfave/cli" 20 | ) 21 | 22 | func AniListApp(app *cli.App) *cli.App { 23 | app.Flags = []cli.Flag{ 24 | cli.BoolFlag{ 25 | Name: "r, refresh", 26 | Usage: "refreshes cached list", 27 | }, 28 | cli.IntFlag{ 29 | Name: "max", 30 | Usage: "visible entries threshold", 31 | }, 32 | cli.BoolFlag{ 33 | Name: "all, a", 34 | Usage: "display all entries; same as --max -1", 35 | }, 36 | cli.StringFlag{ 37 | Name: "status", 38 | Usage: "display entries only with given status " + 39 | "[watching|planning|completed|repeating|paused|dropped]", 40 | }, 41 | } 42 | 43 | app.Commands = []cli.Command{ 44 | cli.Command{ 45 | Name: "switch", 46 | Usage: "Switches app mode between AniList and MyAnimeList", 47 | UsageText: "mal mal", 48 | Action: switchToMal, 49 | }, 50 | cli.Command{ 51 | Name: "eps", 52 | Aliases: []string{"episodes", "e"}, 53 | Category: "Update", 54 | Usage: "Set the watched episodes value. " + 55 | "If n not specified, the number will be increased by one", 56 | UsageText: "mal eps ", 57 | Action: alSetEntryEpisodes, 58 | }, 59 | cli.Command{ 60 | Name: "status", 61 | Category: "Update", 62 | Usage: "Set your status for selected entry", 63 | UsageText: "mal status [watching|planning|completed|dropped|paused|repeating]", 64 | Action: alSetEntryStatus, 65 | }, 66 | cli.Command{ 67 | Name: "cmpl", 68 | Category: "Update", 69 | Usage: "Set entry status to completed", 70 | UsageText: "mal cmpl", 71 | Action: alSetEntryStatusCompleted, 72 | }, 73 | cli.Command{ 74 | Name: "score", 75 | Category: "Update", 76 | Usage: "Set your rating for selected entry", 77 | UsageText: "mal score <0-10>", 78 | Action: alSetEntryScore, 79 | }, 80 | cli.Command{ 81 | Name: "delete", 82 | Aliases: []string{"del"}, 83 | Category: "Update", 84 | Usage: "Delete entry", 85 | UsageText: "mal del", 86 | Action: alDeleteEntry, 87 | }, 88 | cli.Command{ 89 | Name: "sel", 90 | Aliases: []string{"select", "s"}, 91 | Category: "Config", 92 | Usage: "Select an entry", 93 | UsageText: "mal sel [entry title]", 94 | Action: alSelectEntry, 95 | Flags: []cli.Flag{ 96 | cli.BoolFlag{ 97 | Name: "rand", 98 | Usage: "select random entry from \"planning\" list", 99 | }, 100 | }, 101 | }, 102 | cli.Command{ 103 | Name: "selected", 104 | Aliases: []string{"curr"}, 105 | Category: "Action", 106 | Usage: "Display info about currently selected entry", 107 | UsageText: "mal curr", 108 | Action: alShowSelectedEntry, 109 | }, 110 | cli.Command{ 111 | Name: "nyaa", 112 | Aliases: []string{"n"}, 113 | Category: "Action", 114 | Usage: "Open interactive torrent search", 115 | Action: alNyaaCui, 116 | Flags: []cli.Flag{ 117 | cli.BoolFlag{ 118 | Name: "alt", 119 | Usage: "choose an alternative title", 120 | }, 121 | cli.StringFlag{ 122 | Name: "custom", 123 | Usage: "Adds custom nyaa search query for the selected entry", 124 | }, 125 | }, 126 | }, 127 | cli.Command{ 128 | Name: "nyaa-web", 129 | Aliases: []string{"nw"}, 130 | Category: "Action", 131 | Usage: "Open torrent search in browser", 132 | UsageText: "mal nyaa-web", 133 | Action: alNyaaWebsite, 134 | Flags: []cli.Flag{ 135 | cli.BoolFlag{ 136 | Name: "alt", 137 | Usage: "choose an alternative title", 138 | }, 139 | }, 140 | }, 141 | cli.Command{ 142 | Name: "web", 143 | Aliases: []string{"website", "open", "url"}, 144 | Category: "Action", 145 | Usage: "Open url associated with selected entry or change url if provided", 146 | UsageText: "mal web ", 147 | Action: alOpenWebsite, 148 | Flags: []cli.Flag{ 149 | cli.BoolFlag{ 150 | Name: "clear", 151 | Usage: "Clear url for current entry", 152 | }, 153 | }, 154 | Subcommands: []cli.Command{ 155 | cli.Command{ 156 | Name: "get-all", 157 | Usage: "Print all set urls", 158 | UsageText: "mal web get-all", 159 | Action: alPrintWebsites, 160 | }, 161 | }, 162 | }, 163 | cli.Command{ 164 | Name: "search", 165 | Aliases: []string{"add", "browse"}, 166 | Category: "Action", 167 | Usage: "Browse online database and new entries", 168 | UsageText: "mal search [search query]", 169 | Action: alSearch, 170 | SkipFlagParsing: true, 171 | }, 172 | cli.Command{ 173 | Name: "stats", 174 | Category: "Action", 175 | Usage: "Show your account statistics", 176 | UsageText: "mal stats", 177 | Action: alStats, 178 | }, 179 | cli.Command{ 180 | Name: "airing", 181 | Aliases: []string{"broadcast"}, 182 | Category: "Action", 183 | Usage: "Print airing time of next episode", 184 | UsageText: "mal airing [episode]", 185 | Action: alAiringTime, 186 | }, 187 | cli.Command{ 188 | Name: "music", 189 | Category: "Action", 190 | Usage: "Print opening and ending themes", 191 | UsageText: "mal music", 192 | Action: alPrintMusic, 193 | }, 194 | cli.Command{ 195 | Name: "copy", 196 | Category: "Action", 197 | Usage: "Copy selected value into system clipboard", 198 | UsageText: "mal copy [title|url]", 199 | Action: alCopyIntoClipboard, 200 | }, 201 | cli.Command{ 202 | Name: "anilist", 203 | Aliases: []string{"al"}, 204 | Category: "Action", 205 | Usage: "Open selected entry's AniList site", 206 | UsageText: "mal al", 207 | Action: alOpenEntrySite, 208 | }, 209 | cli.Command{ 210 | Name: "mal", 211 | Category: "Action", 212 | Usage: "Open selected entry's MyAnimeList site", 213 | UsageText: "mal mal", 214 | Action: alOpenMalSite, 215 | }, 216 | cli.Command{ 217 | Name: "airnot", 218 | Category: "Action", 219 | Usage: "Fetch airing notifications", 220 | UsageText: "mal airnot", 221 | Action: alAiringNotifications, 222 | Flags: []cli.Flag{ 223 | cli.UintFlag{ 224 | Name: "max", 225 | Usage: "Set max amount of notifications displayed", 226 | Value: 50, 227 | }, 228 | }, 229 | }, 230 | cli.Command{ 231 | Name: "cfg", 232 | Aliases: []string{"config", "configuration"}, 233 | Category: "Config", 234 | Usage: "Change config values", 235 | Subcommands: cli.Commands{ 236 | cli.Command{ 237 | Name: "max", 238 | Aliases: []string{"visible"}, 239 | Usage: "Change amount of displayed entries", 240 | UsageText: "mal cfg max [number]", 241 | Action: configChangeMax, 242 | }, 243 | cli.Command{ 244 | Name: "list-width", 245 | Usage: "Change the width of displayed list", 246 | UsageText: "mal cfg list-width [width]", 247 | SkipFlagParsing: true, 248 | Action: configChangeListWidth, 249 | }, 250 | cli.Command{ 251 | Name: "status", 252 | Usage: "Status value of displayed entries", 253 | UsageText: "mal cfg status [watching|planning|completed|dropped|paused|repeating]", 254 | Action: configChangeAlStatus, 255 | }, 256 | cli.Command{ 257 | Name: "status-auto-update", 258 | Usage: "Allows entry to be automatically set to completed when number of all episodes is reached or exceeded", 259 | UsageText: "mal cfg status-auto-update [off|normal|after-threshold]", 260 | Action: configChangeAutoUpdateMode, 261 | }, 262 | cli.Command{ 263 | Name: "sort", 264 | Usage: "Specifies sorting mode for the displayed table", 265 | UsageText: "mal cfg sort [last-updated|title|progress|score]", 266 | Action: configChangeSorting, 267 | }, 268 | cli.Command{ 269 | Name: "browser", 270 | Usage: "Specifies a browser to use", 271 | UsageText: "mal cfg browser [browser_path]", 272 | Action: configChangeBrowser, 273 | Flags: []cli.Flag{ 274 | cli.BoolFlag{ 275 | Name: "clear", 276 | Usage: "Clear browser path (return to default)", 277 | }, 278 | }, 279 | }, 280 | cli.Command{ 281 | Name: "torrent", 282 | Usage: "Sets path to torrent client and it args", 283 | UsageText: "mal cfg torrent [path] [args...]", 284 | SkipFlagParsing: true, 285 | Action: configChangeTorrent, 286 | }, 287 | cli.Command{ 288 | Name: "nyaa-quality", 289 | Usage: "Sets default quality filter for nyaa search", 290 | UsageText: "mal cfg nyaa-quality [quality_text]", 291 | SkipFlagParsing: true, 292 | Action: configChangeNyaaQuality, 293 | }, 294 | }, 295 | }, 296 | } 297 | 298 | app.Action = cli.ActionFunc(aniListDefaultAction) 299 | 300 | return app 301 | } 302 | 303 | func aniListDefaultAction(ctx *cli.Context) error { 304 | al, err := loadAniList(ctx) 305 | if err != nil { 306 | return err 307 | } 308 | cfg := LoadConfig() 309 | status := cfg.ALStatus 310 | if statusFlag := ctx.String("status"); statusFlag != "" { 311 | status = anilist.ParseStatus(statusFlag) 312 | } 313 | list := alGetList(al, status) 314 | 315 | sort.Slice(list, func(i, j int) bool { 316 | return list[i].UpdatedAt > list[j].UpdatedAt 317 | }) 318 | 319 | var visibleEntries int 320 | if visibleEntries = ctx.Int("max"); visibleEntries == 0 { 321 | // `Max` flag not specified, get value from config 322 | visibleEntries = cfg.MaxVisibleEntries 323 | } 324 | if visibleEntries > len(list) || visibleEntries < 0 || ctx.Bool("all") { 325 | visibleEntries = len(list) 326 | } 327 | 328 | numberFieldWidth := int(math.Max(math.Ceil(math.Log10(float64(visibleEntries+1))), 2)) 329 | titleWidth := cfg.ListWidth - numberFieldWidth - 8 - 6 330 | fmt.Printf("%*s%*.*s%8s%6s\n", 331 | numberFieldWidth, "No", titleWidth, titleWidth, "Title", "Eps", "Score") 332 | fmt.Println(strings.Repeat("=", cfg.ListWidth)) 333 | var pattern string 334 | if al.User.MediaListOptions.ScoreFormat == anilist.Point10Decimal { 335 | pattern = "%*d%*.*s%8s%6.1f\n" 336 | } else { 337 | pattern = "%*d%*.*s%8s%6.f\n" 338 | } 339 | var entry *anilist.MediaListEntry 340 | for i := visibleEntries - 1; i >= 0; i-- { 341 | entry = &list[i] 342 | if entry.Id == cfg.ALSelectedID { 343 | color.HiYellow(pattern, numberFieldWidth, i+1, titleWidth, titleWidth, 344 | entry.Title.UserPreferred, 345 | fmt.Sprintf("%d/%d", entry.Progress, entry.Episodes), 346 | entry.Score) 347 | } else { 348 | fmt.Printf(pattern, numberFieldWidth, i+1, titleWidth, titleWidth, 349 | entry.Title.UserPreferred, 350 | fmt.Sprintf("%d/%d", entry.Progress, entry.Episodes), 351 | entry.Score) 352 | } 353 | } 354 | 355 | return nil 356 | } 357 | 358 | func switchToMal(ctx *cli.Context) error { 359 | appCfg := AppConfig{} 360 | LoadJsonFile(AppConfigFile, &appCfg) 361 | appCfg.Mode = MalMode 362 | if err := SaveJsonFile(AppConfigFile, &appCfg); err != nil { 363 | return err 364 | } 365 | fmt.Println("App mode switched to MyAnimeList") 366 | return nil 367 | } 368 | 369 | func alSetEntryEpisodes(ctx *cli.Context) error { 370 | al, entry, cfg, err := loadAniListFull(ctx) 371 | if err != nil { 372 | return err 373 | } 374 | epsBefore := entry.Progress 375 | 376 | if arg := ctx.Args().First(); arg != "" { 377 | n, err := strconv.Atoi(arg) 378 | if err != nil { 379 | return fmt.Errorf("n must be a non-negative integer") 380 | } 381 | if n < 0 { 382 | return fmt.Errorf("n can't be lower than 0") 383 | } 384 | entry.Progress = n 385 | } else if cfg.StatusAutoUpdateMode == AfterThreshold && entry.Progress == 0 { 386 | entry.Progress += 2 387 | } else { 388 | entry.Progress++ 389 | } 390 | 391 | alStatusAutoUpdate(cfg, entry) 392 | 393 | if err = anilist.SaveMediaListEntryWaitAnimation(entry, al.Token); err != nil { 394 | return err 395 | } 396 | if err = saveAniListAnimeLists(al); err != nil { 397 | return err 398 | } 399 | 400 | fmt.Println("Updated successfully") 401 | alPrintEntryDetailsAfterUpdatedEpisodes(entry, epsBefore, al.User.MediaListOptions.ScoreFormat) 402 | return nil 403 | } 404 | 405 | func alStatusAutoUpdate(cfg *Config, entry *anilist.MediaListEntry) { 406 | if cfg.StatusAutoUpdateMode == Off || entry.Episodes == 0 { 407 | return 408 | } 409 | 410 | if (cfg.StatusAutoUpdateMode == Normal && entry.Progress >= entry.Episodes) || 411 | (cfg.StatusAutoUpdateMode == AfterThreshold && entry.Progress > entry.Episodes) { 412 | entry.Status = anilist.Completed 413 | entry.Progress = entry.Episodes 414 | return 415 | } 416 | 417 | if entry.Status == anilist.Completed && entry.Progress < entry.Episodes { 418 | entry.Status = anilist.Current 419 | return 420 | } 421 | } 422 | 423 | func alSetEntryStatus(ctx *cli.Context) error { 424 | al, entry, _, err := loadAniListFull(ctx) 425 | if err != nil { 426 | return err 427 | } 428 | 429 | status := anilist.ParseStatus(ctx.Args().First()) 430 | if status == anilist.All { 431 | return fmt.Errorf("invalid status; possible values: " + 432 | "watching|planning|completed|dropped|paused|repeating") 433 | } 434 | 435 | entry.Status = status 436 | 437 | if err = anilist.SaveMediaListEntryWaitAnimation(entry, al.Token); err != nil { 438 | return err 439 | } 440 | if err = saveAniListAnimeLists(al); err != nil { 441 | return err 442 | } 443 | 444 | fmt.Println("Updated successfully") 445 | alPrintEntryDetails(entry, al.User.MediaListOptions.ScoreFormat) 446 | return nil 447 | } 448 | 449 | func alSetEntryStatusCompleted(ctx *cli.Context) error { 450 | return ctx.App.Run([]string{"", "status", "completed"}) 451 | } 452 | 453 | func alSetEntryScore(ctx *cli.Context) error { 454 | al, entry, _, err := loadAniListFull(ctx) 455 | if err != nil { 456 | return err 457 | } 458 | 459 | score, err := parseScore(ctx.Args().First(), al.User.MediaListOptions.ScoreFormat) 460 | if err != nil { 461 | return err 462 | } 463 | 464 | entry.Score = score 465 | 466 | if err = anilist.SaveMediaListEntryWaitAnimation(entry, al.Token); err != nil { 467 | return err 468 | } 469 | if err = saveAniListAnimeLists(al); err != nil { 470 | return err 471 | } 472 | 473 | fmt.Println("Updated successfully") 474 | alPrintEntryDetails(entry, al.User.MediaListOptions.ScoreFormat) 475 | return nil 476 | } 477 | 478 | func parseScore(score string, scoreFormat anilist.ScoreFormat) (float32, error) { 479 | maxScore := 0 480 | switch scoreFormat { 481 | case anilist.Point10Decimal: 482 | maxScore = 10 483 | decimalPartIdx := strings.Index(score, ".") 484 | if decimalPartIdx == -1 { 485 | break 486 | } else if decimalPartIdx == len(score)-1 { 487 | score = score[:decimalPartIdx] 488 | break 489 | } 490 | 491 | decimalPart := score[(decimalPartIdx + 1):] 492 | if len(decimalPart) > 1 { 493 | return 0, fmt.Errorf("invalid score; up to 1 decimal place allowed") 494 | } 495 | 496 | parsedScore, err := strconv.ParseFloat(score, 32) 497 | if err != nil || parsedScore < 0.0 || parsedScore > 10.0 { 498 | return 0, fmt.Errorf("invalid score; valid range: <0;10>") 499 | } 500 | return float32(parsedScore), err 501 | case anilist.Point100: 502 | maxScore = 100 503 | case anilist.Point10: 504 | maxScore = 10 505 | case anilist.Point5: 506 | maxScore = 5 507 | case anilist.Point3: 508 | maxScore = 3 509 | } 510 | parsedScore, err := strconv.Atoi(score) 511 | if err != nil || parsedScore < 0 || parsedScore > maxScore { 512 | return 0, fmt.Errorf("invalid score; valid range: <0;%d>", maxScore) 513 | } 514 | return float32(parsedScore), err 515 | } 516 | 517 | func alDeleteEntry(ctx *cli.Context) error { 518 | al, entry, _, err := loadAniListFull(ctx) 519 | if err != nil { 520 | return err 521 | } 522 | 523 | if err := anilist.DeleteMediaListEntry(entry, al.Token); err != nil { 524 | return err 525 | } 526 | 527 | fmt.Println("Entry deleted successfully") 528 | alPrintEntryDetails(entry, al.User.MediaListOptions.ScoreFormat) 529 | 530 | al.List = al.List.DeleteById(entry.ListId) 531 | return saveAniListAnimeLists(al) 532 | } 533 | 534 | func alSelectEntry(ctx *cli.Context) error { 535 | if ctx.Bool("rand") { 536 | return alSelectRandomEntry(ctx) 537 | } 538 | 539 | al, err := loadAniList(ctx) 540 | if err != nil { 541 | return err 542 | } 543 | cfg := LoadConfig() 544 | 545 | searchTerm := strings.ToLower(strings.Join(ctx.Args(), " ")) 546 | if searchTerm == "" { 547 | return alFuzzySelectEntry(ctx) 548 | } 549 | 550 | var matchedEntry *anilist.MediaListEntry = nil 551 | for i, entry := range al.List { 552 | title := entry.Title.Romaji + " " + entry.Title.English + " " + entry.Title.Native 553 | if strings.ToLower(entry.Title.UserPreferred) == searchTerm { 554 | matchedEntry = &al.List[i] 555 | break 556 | } 557 | if strings.Contains(strings.ToLower(title), searchTerm) { 558 | if matchedEntry != nil { 559 | matchedEntry = nil 560 | break 561 | } 562 | matchedEntry = &al.List[i] 563 | } 564 | } 565 | if matchedEntry != nil { 566 | alSaveSelection(cfg, matchedEntry, al.User.MediaListOptions.ScoreFormat) 567 | return nil 568 | } 569 | 570 | return alFuzzySelectEntry(ctx) 571 | } 572 | 573 | func alSelectRandomEntry(ctx *cli.Context) error { 574 | al, err := loadAniList(ctx) 575 | if err != nil { 576 | return err 577 | } 578 | 579 | planToWatchList := alGetList(al, anilist.Planning) 580 | idx := rand.New(rand.NewSource(time.Now().UnixNano())).Intn(len(planToWatchList)) 581 | alSaveSelection(LoadConfig(), &planToWatchList[idx], al.User.MediaListOptions.ScoreFormat) 582 | 583 | return nil 584 | } 585 | 586 | func alSaveSelection(cfg *Config, entry *anilist.MediaListEntry, scoreFormat anilist.ScoreFormat) { 587 | cfg.ALSelectedID = entry.Id 588 | cfg.Save() 589 | 590 | fmt.Println("Selected entry:") 591 | alPrintEntryDetails(entry, scoreFormat) 592 | } 593 | 594 | func alShowSelectedEntry(ctx *cli.Context) error { 595 | al, entry, _, err := loadAniListFull(ctx) 596 | if err != nil { 597 | return err 598 | } 599 | alPrintEntryDetails(entry, al.User.MediaListOptions.ScoreFormat) 600 | return nil 601 | } 602 | 603 | func alNyaaWebsite(ctx *cli.Context) error { 604 | al, err := loadAniList(ctx) 605 | if err != nil { 606 | return err 607 | } 608 | cfg := LoadConfig() 609 | 610 | entry := al.GetMediaListById(cfg.ALSelectedID) 611 | if entry == nil { 612 | return fmt.Errorf("no entry selected") 613 | } 614 | 615 | var searchTerm string 616 | if ctx.Bool("alt") { 617 | fmt.Printf("Select desired title\n\n") 618 | if searchTerm = chooseStrFromSlice(sliceOfEntryTitles(entry)); searchTerm == "" { 619 | return fmt.Errorf("no alternative titles") 620 | } 621 | } else { 622 | searchTerm = entry.Title.Romaji 623 | } 624 | 625 | address := "https://nyaa.si/?f=0&c=1_2&q=" + url.QueryEscape(searchTerm) 626 | if path := cfg.BrowserPath; path == "" { 627 | open.Start(address) 628 | } else { 629 | open.StartWith(address, path) 630 | } 631 | 632 | fmt.Println("Searched for:") 633 | alPrintEntryDetails(entry, al.User.MediaListOptions.ScoreFormat) 634 | return nil 635 | } 636 | 637 | func alOpenWebsite(ctx *cli.Context) error { 638 | al, err := loadAniList(ctx) 639 | if err != nil { 640 | return nil 641 | } 642 | 643 | cfg := LoadConfig() 644 | 645 | entry := al.GetMediaListById(cfg.ALSelectedID) 646 | if entry == nil { 647 | return fmt.Errorf("no entry selected") 648 | } 649 | 650 | if newUrl := ctx.Args().First(); newUrl != "" { 651 | cfg.Websites[entry.IdMal] = newUrl 652 | cfg.Save() 653 | 654 | fmt.Print("Entry: ") 655 | color.HiYellow("%s", entry.Title.UserPreferred) 656 | fmt.Print("URL: ") 657 | color.HiRed("%v", cfg.Websites[entry.IdMal]) 658 | 659 | return nil 660 | } 661 | 662 | if ctx.Bool("clear") { 663 | delete(cfg.Websites, entry.IdMal) 664 | cfg.Save() 665 | 666 | fmt.Println("Entry cleared") 667 | return nil 668 | } 669 | 670 | if entryUrl, ok := cfg.Websites[entry.IdMal]; ok { 671 | if path := cfg.BrowserPath; path == "" { 672 | open.Start(entryUrl) 673 | } else { 674 | open.StartWith(entryUrl, path) 675 | } 676 | 677 | fmt.Println("Opened website for:") 678 | alPrintEntryDetails(entry, al.User.MediaListOptions.ScoreFormat) 679 | fmt.Fprintf(color.Output, "URL: %v\n", color.CyanString("%v", entryUrl)) 680 | } else { 681 | fmt.Println("Nothing to open") 682 | } 683 | 684 | return nil 685 | } 686 | 687 | func alPrintWebsites(ctx *cli.Context) error { 688 | al, err := loadAniList(ctx) 689 | if err != nil { 690 | return err 691 | } 692 | 693 | cfg := LoadConfig() 694 | 695 | for k, v := range cfg.Websites { 696 | entryUrl := fmt.Sprintf("\033[3%d;%dm%s\033[0m ", 3, 1, v) 697 | 698 | var title string 699 | if entry := al.GetMediaListByMalId(k); entry != nil { 700 | title = entry.Title.UserPreferred 701 | } 702 | 703 | fmt.Fprintf(color.Output, "%6d (%s): %s\n", k, title, entryUrl) 704 | } 705 | 706 | return nil 707 | } 708 | 709 | func alStats(ctx *cli.Context) error { 710 | al, err := loadAniList(ctx) 711 | if err != nil { 712 | return err 713 | } 714 | 715 | yellow := color.New(color.FgHiYellow).SprintFunc() 716 | red := color.New(color.FgHiRed).SprintFunc() 717 | cyan := color.New(color.FgHiCyan).SprintFunc() 718 | magenta := color.New(color.FgHiMagenta).SprintFunc() 719 | 720 | lists := [6]List{ 721 | alGetList(al, anilist.Current), 722 | alGetList(al, anilist.Planning), 723 | alGetList(al, anilist.Completed), 724 | alGetList(al, anilist.Repeating), 725 | alGetList(al, anilist.Paused), 726 | alGetList(al, anilist.Dropped), 727 | } 728 | 729 | totalShows := 0 730 | totalTimeSpentWatching := 0 731 | totalEpisodesWatched := 0 732 | for _, list := range lists { 733 | if len(list) == 0 { 734 | continue 735 | } 736 | totalShows += len(list) 737 | episodesWatched := 0 738 | timeSpentWatching := 0 739 | for _, entry := range list { 740 | timeSpentWatching += (entry.Progress * entry.Duration) + 741 | (entry.Repeat * entry.Episodes * entry.Duration) 742 | episodesWatched += entry.Progress + entry.Episodes*entry.Repeat 743 | } 744 | totalTimeSpentWatching += timeSpentWatching 745 | totalEpisodesWatched += episodesWatched 746 | 747 | timeSpentWatchingFormatted, _ := durafmt.ParseString(fmt.Sprint(timeSpentWatching, "m")) 748 | 749 | fmt.Fprintf(color.Output, 750 | `%s: 751 | entries: %s 752 | episodes: %s 753 | time spent watching: %s 754 | `, 755 | list[0].Status.String(), 756 | red(len(list)), 757 | magenta(episodesWatched), 758 | cyan(timeSpentWatchingFormatted), 759 | ) 760 | } 761 | 762 | totalTimeSpentWatchingDuration, _ := time.ParseDuration( 763 | fmt.Sprint(totalTimeSpentWatching, "m")) 764 | 765 | fmt.Println() 766 | fmt.Fprintln(color.Output, "Total episodes watched:", red(totalEpisodesWatched)) 767 | fmt.Fprintln(color.Output, "Total shows:", red(totalShows)) 768 | fmt.Fprintf(color.Output, 769 | "Total time spent watching: %s (%s days)\n", 770 | yellow(durafmt.Parse(totalTimeSpentWatchingDuration).String()), 771 | cyan(int(totalTimeSpentWatchingDuration.Hours()/24+0.5))) 772 | 773 | return nil 774 | } 775 | 776 | func alAiringTime(ctx *cli.Context) error { 777 | al, entry, cfg, err := loadAniListFull(ctx) 778 | if err != nil { 779 | return err 780 | } 781 | 782 | var episode int 783 | if argsLen := ctx.NArg(); argsLen == 1 { 784 | episode, err = strconv.Atoi(ctx.Args().First()) 785 | if err != nil { 786 | return err 787 | } 788 | } else if argsLen > 1 { 789 | return fmt.Errorf("too many arguments") 790 | } else { 791 | episode = entry.Progress 792 | if episode == 0 || 793 | cfg.StatusAutoUpdateMode != AfterThreshold && entry.Progress < entry.Episodes { 794 | 795 | episode++ 796 | } 797 | } 798 | 799 | schedule, err := anilist.QueryAiringScheduleWaitAnimation(entry.Id, episode, al.Token) 800 | if err != nil { 801 | return err 802 | } 803 | 804 | airingAt := time.Unix(int64(schedule.AiringAt), 0) 805 | 806 | yellow := color.New(color.FgHiYellow).SprintFunc() 807 | red := color.New(color.FgHiRed).SprintFunc() 808 | cyan := color.New(color.FgHiCyan).SprintFunc() 809 | fmt.Fprintf( 810 | color.Output, 811 | "Title: %s\n"+ 812 | "Episode: %s\n"+ 813 | "Airing at: %s\n", 814 | yellow(entry.Title.UserPreferred), 815 | red(schedule.Episode), 816 | cyan(airingAt.Format("15:04:05 02-01-2006 MST (Monday)")), 817 | ) 818 | 819 | tua := schedule.TimeUntilAiring 820 | if tua < 0 { 821 | tua *= -1 822 | } 823 | timeUntilAiring, err := durafmt.ParseString(strconv.Itoa(tua) + "s") 824 | if err != nil { 825 | fmt.Println(err) 826 | } else if schedule.TimeUntilAiring < 0 { 827 | fmt.Fprintln(color.Output, "Episode aired", cyan(timeUntilAiring), "ago") 828 | } else { 829 | fmt.Fprintln(color.Output, "Time until airing:", cyan(timeUntilAiring)) 830 | } 831 | return nil 832 | } 833 | 834 | func alPrintMusic(ctx *cli.Context) error { 835 | _, entry, _, err := loadAniListFull(ctx) 836 | if err != nil { 837 | return err 838 | } 839 | 840 | details, err := mal.FetchDetailsWithAnimation(&mal.Client{}, &mal.Anime{ID: entry.IdMal}) 841 | if err != nil { 842 | return err 843 | } 844 | 845 | printThemes := func(themes []string) { 846 | for _, theme := range themes { 847 | fmt.Fprintf( 848 | color.Output, " %s\n", 849 | color.HiYellowString("%s", strings.TrimSpace(theme))) 850 | } 851 | } 852 | 853 | fmt.Fprintln(color.Output, "Openings:") 854 | printThemes(details.OpeningThemes) 855 | 856 | fmt.Fprintln(color.Output, "\nEndings:") 857 | printThemes(details.EndingThemes) 858 | 859 | return nil 860 | } 861 | 862 | func alCopyIntoClipboard(ctx *cli.Context) error { 863 | _, entry, cfg, err := loadAniListFull(ctx) 864 | if err != nil { 865 | return err 866 | } 867 | 868 | var text string 869 | 870 | switch strings.ToLower(ctx.Args().First()) { 871 | case "title": 872 | alts := sliceOfEntryTitles(entry) 873 | fmt.Printf("Select desired title\n\n") 874 | if text = chooseStrFromSlice(alts); text == "" { 875 | return fmt.Errorf("no alternative titles") 876 | } 877 | case "url": 878 | entryUrl, ok := cfg.Websites[entry.IdMal] 879 | if !ok { 880 | return fmt.Errorf("no url to copy") 881 | } 882 | text = entryUrl 883 | default: 884 | return fmt.Errorf("usage: mal copy [title|url]") 885 | } 886 | 887 | if err = clipboard.WriteAll(text); err == nil { 888 | fmt.Fprintln(color.Output, "Text", color.HiYellowString("%s", text), "copied into clipboard") 889 | } 890 | 891 | return err 892 | } 893 | 894 | func alOpenEntrySite(ctx *cli.Context) error { 895 | al, entry, cfg, err := loadAniListFull(ctx) 896 | if err != nil { 897 | return err 898 | } 899 | 900 | uri := fmt.Sprintf("%s/%s/%d", anilist.ALDomain, strings.ToLower(entry.Type), entry.Id) 901 | if path := cfg.BrowserPath; path == "" { 902 | open.Start(uri) 903 | } else { 904 | open.StartWith(uri, path) 905 | } 906 | fmt.Println("Opened website for:") 907 | alPrintEntryDetails(entry, al.User.MediaListOptions.ScoreFormat) 908 | 909 | return nil 910 | } 911 | 912 | func alOpenMalSite(ctx *cli.Context) error { 913 | al, entry, cfg, err := loadAniListFull(ctx) 914 | if err != nil { 915 | return err 916 | } 917 | 918 | openMalSite(cfg, entry.IdMal) 919 | fmt.Println("Opened website for:") 920 | alPrintEntryDetails(entry, al.User.MediaListOptions.ScoreFormat) 921 | 922 | return nil 923 | } 924 | 925 | func alAiringNotifications(ctx *cli.Context) error { 926 | al, err := loadAniList(ctx) 927 | if err != nil { 928 | return err 929 | } 930 | 931 | notifications, err := anilist.QueryAiringNotificationsWaitAnimation( 932 | 1, int(ctx.Uint("max")), false, al.Token) 933 | if err != nil { 934 | return err 935 | } 936 | sort.SliceStable(notifications, func(i, j int) bool { 937 | return notifications[i].CreatedAt < notifications[j].CreatedAt 938 | }) 939 | 940 | cyan := color.New(color.FgHiCyan).SprintFunc() 941 | red := color.New(color.FgHiRed).SprintFunc() 942 | yellow := color.New(color.FgHiYellow).SprintFunc() 943 | for _, n := range notifications { 944 | t := time.Unix(int64(n.CreatedAt), 0).Format("02-01-2006 15:04") 945 | fmt.Fprintf(color.Output, "[%s] Episode %s of %s aired\n", 946 | cyan(t), red(n.Episode), yellow(n.Title.UserPreferred)) 947 | } 948 | 949 | return nil 950 | } 951 | -------------------------------------------------------------------------------- /anilist_app_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/aqatl/mal/anilist" 7 | ) 8 | 9 | func TestParseScore(t *testing.T) { 10 | { 11 | _, err := parseScore("0", anilist.Point10) 12 | if err != nil { 13 | t.Error(err) 14 | } 15 | } 16 | { 17 | score, err := parseScore("-1", anilist.Point10) 18 | if err == nil { 19 | t.Error("Expected fail, got", score) 20 | } 21 | } 22 | { 23 | score, err := parseScore("-1", anilist.Point10Decimal) 24 | if err == nil { 25 | t.Error("Expected fail, got", score) 26 | } 27 | } 28 | { 29 | score, err := parseScore("-1.5", anilist.Point10Decimal) 30 | if err == nil { 31 | t.Error("Expected fail, got", score) 32 | } 33 | } 34 | { 35 | score, err := parseScore("10.1", anilist.Point10Decimal) 36 | if err == nil { 37 | t.Error("Expected fail, got", score) 38 | } 39 | } 40 | { 41 | _, err := parseScore("10", anilist.Point10Decimal) 42 | if err != nil { 43 | t.Error(err) 44 | } 45 | } 46 | { 47 | _, err := parseScore("10.0", anilist.Point10Decimal) 48 | if err != nil { 49 | t.Error(err) 50 | } 51 | } 52 | { 53 | score, err := parseScore("5.50", anilist.Point10Decimal) 54 | if err == nil { 55 | t.Error("Expected fail, got", score) 56 | } 57 | } 58 | { 59 | _, err := parseScore("5.1", anilist.Point10Decimal) 60 | if err != nil { 61 | t.Error(err) 62 | } 63 | } 64 | { 65 | score, err := parseScore("5.1", anilist.Point10) 66 | if err == nil { 67 | t.Error("Expected fail, got", score) 68 | } 69 | } 70 | { 71 | score, err := parseScore("11", anilist.Point10) 72 | if err == nil { 73 | t.Error("Expected fail, got", score) 74 | } 75 | } 76 | { 77 | _, err := parseScore("10", anilist.Point10) 78 | if err != nil { 79 | t.Error(err) 80 | } 81 | } 82 | { 83 | _, err := parseScore("3", anilist.Point3) 84 | if err != nil { 85 | t.Error(err) 86 | } 87 | } 88 | { 89 | score, err := parseScore("4", anilist.Point3) 90 | if err == nil { 91 | t.Error("Expected fail, got", score) 92 | } 93 | } 94 | { 95 | _, err := parseScore("5", anilist.Point5) 96 | if err != nil { 97 | t.Error(err) 98 | } 99 | } 100 | { 101 | _, err := parseScore("5", anilist.Point5) 102 | if err != nil { 103 | t.Error(err) 104 | } 105 | } 106 | { 107 | _, err := parseScore("100", anilist.Point100) 108 | if err != nil { 109 | t.Error(err) 110 | } 111 | } 112 | { 113 | score, err := parseScore("101", anilist.Point100) 114 | if err == nil { 115 | t.Error("Expected fail, got", score) 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /anilist_utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/aqatl/mal/anilist" 7 | "github.com/aqatl/mal/oauth2" 8 | "github.com/urfave/cli" 9 | "os" 10 | "time" 11 | ) 12 | 13 | type AniList struct { 14 | Token oauth2.OAuthToken 15 | User anilist.User 16 | List 17 | } 18 | 19 | type List []anilist.MediaListEntry 20 | 21 | func (l List) GetMediaListById(id int) *anilist.MediaListEntry { 22 | for i := 0; i < len(l); i++ { 23 | if l[i].Id == id { 24 | return &l[i] 25 | } 26 | } 27 | return nil 28 | } 29 | 30 | func (l List) GetMediaListByMalId(malId int) *anilist.MediaListEntry { 31 | for i := 0; i < len(l); i++ { 32 | if l[i].IdMal == malId { 33 | return &l[i] 34 | } 35 | } 36 | return nil 37 | } 38 | 39 | func (l List) DeleteById(listId int) List { 40 | idx := 0 41 | found := false 42 | for i, entry := range l { 43 | if entry.ListId == listId { 44 | idx = i 45 | found = true 46 | break 47 | } 48 | } 49 | if !found { 50 | return l 51 | } 52 | l = append(l[:idx], l[idx+1:]...) 53 | return l 54 | } 55 | 56 | func sliceOfEntryTitles(entry *anilist.MediaListEntry) []string { 57 | alts := make([]string, 0, 3+len(entry.Synonyms)) 58 | if t := entry.Title.English; t != "" { 59 | alts = append(alts, t) 60 | } 61 | if t := entry.Title.Native; t != "" { 62 | alts = append(alts, t) 63 | } 64 | if t := entry.Title.Romaji; t != "" { 65 | alts = append(alts, t) 66 | } 67 | alts = append(alts, entry.Synonyms...) 68 | return alts 69 | } 70 | 71 | func alGetList(al *AniList, status anilist.MediaListStatus) List { 72 | if status == anilist.All { 73 | return al.List 74 | } else { 75 | list := make(List, 0) 76 | for i := range al.List { 77 | if al.List[i].Status == status { 78 | list = append(list, al.List[i]) 79 | } 80 | } 81 | return list 82 | } 83 | } 84 | 85 | func loadAniListFull(ctx *cli.Context) (al *AniList, entry *anilist.MediaListEntry, cfg *Config, err error) { 86 | al, err = loadAniList(ctx) 87 | if err != nil { 88 | return 89 | } 90 | cfg = LoadConfig() 91 | if cfg.ALSelectedID == 0 { 92 | fmt.Println("No entry selected") 93 | } 94 | entry = al.GetMediaListById(cfg.ALSelectedID) 95 | if entry == nil { 96 | err = fmt.Errorf("no entry found") 97 | } 98 | return 99 | } 100 | 101 | func loadAniList(ctx *cli.Context) (*AniList, error) { 102 | token, err := loadOAuthToken() 103 | if err != nil { 104 | return nil, err 105 | } 106 | al := &AniList{Token: token} 107 | 108 | if err := loadAniListUser(al); err != nil { 109 | return nil, err 110 | } 111 | if ctx.Bool("refresh") { 112 | err = fetchAniListAnimeLists(al) 113 | } else { 114 | err = loadAniListAnimeLists(al) 115 | } 116 | return al, err 117 | } 118 | 119 | func loadOAuthToken() (oauth2.OAuthToken, error) { 120 | token, err := loadCachedOAuthToken() 121 | if err != nil { 122 | if err == anilist.InvalidToken || os.IsNotExist(err) { 123 | token, err = requestAniListToken() 124 | } 125 | } 126 | return token, err 127 | } 128 | 129 | func loadCachedOAuthToken() (oauth2.OAuthToken, error) { 130 | token := oauth2.OAuthToken{} 131 | LoadJsonFile(AniListCredsFile, &token) 132 | if token.Token == "" || token.ExpireDate.Before(time.Now()) { 133 | return token, anilist.InvalidToken 134 | } 135 | return token, nil 136 | } 137 | 138 | func saveOAuthToken(token oauth2.OAuthToken) error { 139 | f, err := os.Create(AniListCredsFile) 140 | defer f.Close() 141 | if err != nil { 142 | return err 143 | } 144 | err = json.NewEncoder(f).Encode(&token) 145 | return err 146 | } 147 | 148 | func requestAniListToken() (token oauth2.OAuthToken, err error) { 149 | token, err = oauth2.OAuthImplicitGrantAuth( 150 | "https://anilist.co/api/v2/oauth/authorize", 151 | LoadConfig().BrowserPath, 152 | 743, 153 | 42505, 154 | ) 155 | if err != nil { 156 | return 157 | } 158 | err = saveOAuthToken(token) 159 | return 160 | } 161 | 162 | func loadAniListUser(al *AniList) error { 163 | if LoadJsonFile(AniListUserFile, &al.User) { 164 | return nil 165 | } 166 | err := anilist.QueryAuthenticatedUser(&al.User, al.Token) 167 | if err == anilist.InvalidToken { 168 | if al.Token, err = requestAniListToken(); err != nil { 169 | return err 170 | } 171 | err = anilist.QueryAuthenticatedUser(&al.User, al.Token) 172 | } 173 | if err == nil { 174 | err = saveAniListUser(&al.User) 175 | } 176 | return err 177 | } 178 | 179 | func saveAniListUser(user *anilist.User) error { 180 | f, err := os.Create(AniListUserFile) 181 | defer f.Close() 182 | if err != nil { 183 | return err 184 | } 185 | err = json.NewEncoder(f).Encode(user) 186 | return err 187 | } 188 | 189 | func loadAniListAnimeLists(al *AniList) error { 190 | f, err := os.Open(AniListCacheFile) 191 | defer f.Close() 192 | if err == nil { 193 | err = json.NewDecoder(f).Decode(&al.List) 194 | return err 195 | } 196 | if !os.IsNotExist(err) { 197 | return err 198 | } 199 | return fetchAniListAnimeLists(al) 200 | } 201 | 202 | func fetchAniListAnimeLists(al *AniList) error { 203 | lists, err := anilist.QueryUserListsWaitAnimation(al.User.Id, al.User.MediaListOptions.ScoreFormat, al.Token) 204 | entryIds := make(map[int]bool) 205 | for i := range lists { 206 | for _, entry := range lists[i].Entries { 207 | if !entryIds[entry.Id] { 208 | al.List = append(al.List, entry) 209 | entryIds[entry.Id] = true 210 | } 211 | } 212 | } 213 | if err == anilist.InvalidToken { 214 | if al.Token, err = requestAniListToken(); err != nil { 215 | return err 216 | } 217 | lists, err = anilist.QueryUserListsWaitAnimation(al.User.Id, al.User.MediaListOptions.ScoreFormat, al.Token) 218 | entryIds := make(map[int]bool) 219 | for i := range lists { 220 | for _, entry := range lists[i].Entries { 221 | if !entryIds[entry.Id] { 222 | al.List = append(al.List, entry) 223 | entryIds[entry.Id] = true 224 | } 225 | } 226 | } 227 | } 228 | if err == nil { 229 | err = saveAniListAnimeLists(al) 230 | } 231 | return err 232 | } 233 | 234 | func saveAniListAnimeLists(al *AniList) error { 235 | f, err := os.Create(AniListCacheFile) 236 | defer f.Close() 237 | if err != nil { 238 | return err 239 | } 240 | err = json.NewEncoder(f).Encode(&al.List) 241 | return err 242 | } 243 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/aqatl/mal/anilist" 7 | "github.com/aqatl/mal/mal" 8 | "github.com/fatih/color" 9 | "github.com/urfave/cli" 10 | "log" 11 | "os" 12 | "path/filepath" 13 | "strconv" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | type Config struct { 19 | Websites map[int]string 20 | 21 | MaxVisibleEntries int 22 | StatusAutoUpdateMode StatusAutoUpdateMode 23 | Sorting Sorting 24 | LastUpdate time.Time 25 | ListWidth int 26 | 27 | BrowserPath string 28 | TorrentClientPath string 29 | TorrentClientArgs []string 30 | NyaaQuality string 31 | 32 | SelectedID int 33 | Status mal.MyStatus 34 | 35 | ALSelectedID int 36 | ALStatus anilist.MediaListStatus 37 | 38 | NyaaAlts []NyaaAlt 39 | } 40 | 41 | func NewConfig() *Config { 42 | return &Config{ 43 | Websites: make(map[int]string), 44 | 45 | MaxVisibleEntries: 20, 46 | StatusAutoUpdateMode: Off, 47 | Sorting: ByLastUpdated, 48 | ListWidth: 80, 49 | 50 | TorrentClientPath: "qbittorrent", 51 | 52 | Status: mal.All, 53 | 54 | ALStatus: anilist.Current, 55 | } 56 | } 57 | 58 | type StatusAutoUpdateMode byte 59 | 60 | const ( 61 | Off StatusAutoUpdateMode = iota 62 | Normal 63 | AfterThreshold 64 | ) 65 | 66 | type Sorting byte 67 | 68 | const ( 69 | ByLastUpdated Sorting = iota 70 | ByTitle 71 | ByWatchedEpisodes 72 | ByScore 73 | ) 74 | 75 | func ParseSorting(sort string) (Sorting, error) { 76 | var sorting Sorting 77 | 78 | switch sort { 79 | case "last-updated": 80 | sorting = ByLastUpdated 81 | case "title": 82 | sorting = ByTitle 83 | case "episodes": 84 | sorting = ByWatchedEpisodes 85 | case "score": 86 | sorting = ByScore 87 | default: 88 | return 0, fmt.Errorf("invalid option; possible values: " + 89 | "last-updated|title|episodes|score") 90 | } 91 | 92 | return sorting, nil 93 | } 94 | 95 | func LoadConfig() (c *Config) { 96 | c = NewConfig() 97 | 98 | f, err := os.Open(MalConfigFile) 99 | defer f.Close() 100 | if os.IsNotExist(err) { 101 | return 102 | } else if err != nil { 103 | log.Printf("Error opening %s file: %v", MalConfigFile, err) 104 | } 105 | 106 | decoder := json.NewDecoder(f) 107 | decoder.Decode(c) 108 | 109 | return 110 | } 111 | 112 | func (cfg *Config) Save() { 113 | f, err := os.Create(MalConfigFile) 114 | defer f.Close() 115 | if err != nil { 116 | log.Printf("Error saving %s file: %v", MalConfigFile, err) 117 | } 118 | 119 | encoder := json.NewEncoder(f) 120 | if err := encoder.Encode(cfg); err != nil { 121 | log.Printf("Error encoding %s file: %v", MalConfigFile, err) 122 | } 123 | } 124 | 125 | func configChangeMax(ctx *cli.Context) error { 126 | cfg := LoadConfig() 127 | 128 | max, err := strconv.Atoi(ctx.Args().First()) 129 | if err != nil || max < 0 { 130 | return fmt.Errorf("invalid value") 131 | } 132 | 133 | cfg.MaxVisibleEntries = max 134 | cfg.Save() 135 | return nil 136 | } 137 | 138 | func configChangeListWidth(ctx *cli.Context) error { 139 | cfg := LoadConfig() 140 | 141 | width, err := strconv.Atoi(ctx.Args().First()) 142 | if err != nil || width < 20 { 143 | return fmt.Errorf("invalid or too small width") 144 | } 145 | 146 | cfg.ListWidth = width 147 | cfg.Save() 148 | return nil 149 | } 150 | 151 | func configChangeMalStatus(ctx *cli.Context) error { 152 | cfg := LoadConfig() 153 | 154 | status := mal.ParseStatus(ctx.Args().First()) 155 | 156 | cfg.Status = status 157 | cfg.Save() 158 | return nil 159 | } 160 | 161 | func configChangeAlStatus(ctx *cli.Context) error { 162 | cfg := LoadConfig() 163 | 164 | status := anilist.ParseStatus(ctx.Args().First()) 165 | 166 | cfg.ALStatus = status 167 | cfg.Save() 168 | 169 | str := status.String() 170 | if status == anilist.All { 171 | str = "All" 172 | } 173 | fmt.Println("New status:", str) 174 | return nil 175 | } 176 | 177 | func configChangeAutoUpdateMode(ctx *cli.Context) error { 178 | arg := strings.ToLower(ctx.Args().First()) 179 | var mode StatusAutoUpdateMode 180 | 181 | if arg == "off" { 182 | mode = Off 183 | } else if arg == "normal" { 184 | mode = Normal 185 | } else if arg == "after-threshold" { 186 | mode = AfterThreshold 187 | } else { 188 | return fmt.Errorf("invalid option; possible values: off|normal|after-threshold") 189 | } 190 | 191 | cfg := LoadConfig() 192 | cfg.StatusAutoUpdateMode = mode 193 | cfg.Save() 194 | 195 | return nil 196 | } 197 | 198 | func configChangeSorting(ctx *cli.Context) error { 199 | sorting, err := ParseSorting(strings.ToLower(ctx.Args().First())) 200 | if err != nil { 201 | return fmt.Errorf("error parsing flags: %v", err) 202 | } 203 | 204 | cfg := LoadConfig() 205 | cfg.Sorting = sorting 206 | cfg.Save() 207 | 208 | return nil 209 | } 210 | 211 | func configChangeBrowser(ctx *cli.Context) error { 212 | cfg := LoadConfig() 213 | 214 | if ctx.Bool("clear") { 215 | cfg.BrowserPath = "" 216 | cfg.Save() 217 | 218 | fmt.Printf("Browser path cleared\n") 219 | return nil 220 | } 221 | 222 | browserPath, err := filepath.Abs(strings.Join(ctx.Args(), " ")) 223 | if err != nil { 224 | return fmt.Errorf("path error: %v", err) 225 | } 226 | 227 | cfg.BrowserPath = browserPath 228 | cfg.Save() 229 | 230 | fmt.Fprintf(color.Output, "New browser path: %v\n", color.HiYellowString("%v", browserPath)) 231 | 232 | return nil 233 | } 234 | 235 | func configChangeTorrent(ctx *cli.Context) error { 236 | cfg := LoadConfig() 237 | 238 | clientPath := ctx.Args().First() 239 | 240 | if clientPath == "" { 241 | return fmt.Errorf("usage: mal cfg torrent ") 242 | } 243 | 244 | cfg.TorrentClientPath = clientPath 245 | cfg.TorrentClientArgs = ctx.Args().Tail() 246 | 247 | cfg.Save() 248 | 249 | fmt.Fprintf( 250 | color.Output, 251 | "New torrent config: %s %s\n", 252 | color.HiYellowString("%s", cfg.TorrentClientPath), 253 | color.HiCyanString("%s", strings.Join(cfg.TorrentClientArgs, " "))) 254 | 255 | return nil 256 | } 257 | 258 | func configChangeNyaaQuality(ctx *cli.Context) error { 259 | cfg := LoadConfig() 260 | 261 | cfg.NyaaQuality = strings.Join(ctx.Args(), " ") 262 | cfg.Save() 263 | 264 | fmt.Fprintf(color.Output, "Changed nyaa default quality filter to %s\n", color.HiYellowString("%s", cfg.NyaaQuality)) 265 | 266 | return nil 267 | } 268 | -------------------------------------------------------------------------------- /dialog/commons.go: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | import "github.com/jroimartin/gocui" 4 | 5 | /* 6 | (x0,y0)---------( , ) 7 | | | 8 | | | 9 | | | 10 | ( , )---------(x1,y1) 11 | */ 12 | type Pos struct { 13 | X0, Y0 int 14 | X1, Y1 int 15 | } 16 | 17 | type Config struct { 18 | Gui *gocui.Gui 19 | Pos 20 | ViewConfig func(*gocui.View) 21 | } 22 | 23 | type CleanUpFunc func(gui *gocui.Gui) error 24 | 25 | func cleanUpFunc(gui *gocui.Gui, viewToDelete string) func(gui *gocui.Gui) error { 26 | currView := gui.CurrentView() 27 | return func(gui *gocui.Gui) error { 28 | gui.DeleteView(viewToDelete) 29 | if currView != nil { 30 | gui.SetCurrentView(currView.Name()) 31 | } 32 | return nil 33 | } 34 | } 35 | 36 | func longestStringLen(slice []string) (maxLen int) { 37 | for _, str := range slice { 38 | if l := len(str); l > maxLen { 39 | maxLen = l 40 | } 41 | } 42 | return 43 | } -------------------------------------------------------------------------------- /dialog/list_select.go: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/jroimartin/gocui" 7 | "reflect" 8 | "strconv" 9 | ) 10 | 11 | const ( 12 | listSelectViewName = "listSelectViewName" 13 | ) 14 | 15 | // Displays a list created from given slice / array. 16 | // If allowMultipleSelection is set, you can select multiple entries with space. 17 | func ListSelect(gui *gocui.Gui, title string, slice interface{}, allowMultipleSelection bool) ( 18 | <-chan []int, CleanUpFunc, error, 19 | ) { 20 | value := reflect.ValueOf(slice) 21 | if k := value.Kind(); k != reflect.Slice && k != reflect.Array { 22 | return nil, nil, fmt.Errorf("slice argument must be a slice or an array") 23 | } 24 | listW := 1 25 | listH := value.Len() 26 | 27 | buf := bytes.Buffer{} 28 | for i := 0; i < value.Len(); i++ { 29 | idxLen, _ := buf.WriteString(strconv.Itoa(i)) 30 | buf.WriteByte('.') 31 | strBytes := []byte(fmt.Sprint(value.Index(i).Interface())) 32 | if bLen := len(strBytes) + idxLen + 2; bLen > listW { 33 | listW = bLen 34 | } 35 | buf.Write(strBytes) 36 | buf.WriteRune('\n') 37 | } 38 | listW += 2 39 | 40 | cleanUp := cleanUpFunc(gui, listSelectViewName) 41 | selectedIdxes, v, err := listSelect(gui, title, listW, listH, allowMultipleSelection) 42 | if v != nil { 43 | buf.WriteTo(v) 44 | } 45 | return selectedIdxes, cleanUp, err 46 | } 47 | 48 | func listSelect(gui *gocui.Gui, title string, listW, listH int, multiSel bool) ( 49 | chan []int, *gocui.View, error, 50 | ) { 51 | w, h := gui.Size() 52 | 53 | //TODO wrap list if list is too wide (handle moving up & down correctly) 54 | viewHeight := listH + 1 55 | if maxHeight := int(float64(h) * 0.8); listH > maxHeight { 56 | viewHeight = maxHeight 57 | } 58 | x0, y0 := w/2-listW/2, h/2-viewHeight/2 59 | x1, y1 := x0+listW, y0+viewHeight 60 | 61 | v, err := gui.SetView(listSelectViewName, x0, y0, x1, y1) 62 | if err == gocui.ErrUnknownView { 63 | err = nil 64 | } 65 | 66 | gui.SetCurrentView(listSelectViewName) 67 | gui.SetViewOnTop(listSelectViewName) 68 | 69 | v.Title = title 70 | v.SelBgColor = gocui.ColorGreen 71 | v.SelFgColor = gocui.ColorBlack 72 | v.Highlight = true 73 | v.Editable = true 74 | 75 | selectedIdxes := make(chan []int) 76 | chanClosed := false 77 | 78 | idxs := make([]int, 0, listH) 79 | v.Editor = gocui.EditorFunc(func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 80 | switch { 81 | case key == gocui.KeyArrowDown || ch == 'j': 82 | _, oy := v.Origin() 83 | _, y := v.Cursor() 84 | y += oy 85 | if y < listH-1 { 86 | v.MoveCursor(0, 1, false) 87 | } 88 | case key == gocui.KeyArrowUp || ch == 'k': 89 | v.MoveCursor(0, -1, false) 90 | case key == gocui.KeySpace: 91 | if !multiSel { 92 | return 93 | } 94 | _, oy := v.Origin() 95 | _, y := v.Cursor() 96 | y += oy 97 | idxsIdx := -1 98 | for i, v := range idxs { 99 | if v == y { 100 | idxsIdx = i 101 | break 102 | } 103 | } 104 | v.SetCursor(0, y-oy) 105 | if idxsIdx != -1 { 106 | idxs = append(idxs[:idxsIdx], idxs[idxsIdx+1:]...) 107 | v.EditDelete(false) 108 | } else { 109 | idxs = append(idxs, y) 110 | v.EditWrite('*') 111 | } 112 | case key == gocui.KeyEnter: 113 | if !chanClosed { 114 | _, oy := v.Origin() 115 | _, y := v.Cursor() 116 | y += oy 117 | contains := false 118 | for _, v := range idxs { 119 | if v == y { 120 | contains = true 121 | break 122 | } 123 | } 124 | if !contains { 125 | idxs = append(idxs, y) 126 | } 127 | 128 | selectedIdxes <- idxs 129 | close(selectedIdxes) 130 | chanClosed = true 131 | } 132 | case key == gocui.KeyCtrlQ || key == gocui.KeyEsc || ch == 'q': 133 | if !chanClosed { 134 | close(selectedIdxes) 135 | chanClosed = true 136 | } 137 | } 138 | }) 139 | 140 | return selectedIdxes, v, err 141 | } 142 | -------------------------------------------------------------------------------- /dialog/ok_dialog.go: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jroimartin/gocui" 6 | "math" 7 | "strings" 8 | "unicode/utf8" 9 | ) 10 | 11 | const okDialogViewName = "okDialogViewName " 12 | 13 | func JustShowOkDialog(gui *gocui.Gui, title, msg string) { 14 | gui.Update(func(gui *gocui.Gui) error { 15 | confirmed, cleanUp, err := OkDialog(FitMessageWithOkButton( 16 | gui, 17 | msg, 18 | Title(title), 19 | )) 20 | if err != nil { 21 | return err 22 | } 23 | go func() { 24 | <-confirmed 25 | gui.Update(cleanUp) 26 | }() 27 | return nil 28 | }) 29 | } 30 | 31 | func OkDialog(config Config) (<-chan struct{}, CleanUpFunc, error) { 32 | cleanUp := cleanUpFunc(config.Gui, okDialogViewName) 33 | v, err := config.Gui.SetView(okDialogViewName, config.X0, config.Y0, config.X1, config.Y1) 34 | if err == gocui.ErrUnknownView { 35 | err = nil 36 | } else if err != nil { 37 | return nil, cleanUp, err 38 | } 39 | 40 | confirmed := make(chan struct{}) 41 | 42 | v.Editor = gocui.EditorFunc(func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 43 | switch key { 44 | case gocui.KeyEnter, gocui.KeySpace: 45 | confirmed <- struct{}{} 46 | } 47 | }) 48 | 49 | config.Gui.SetCurrentView(okDialogViewName) 50 | config.Gui.SetViewOnTop(okDialogViewName) 51 | v.Editable = true 52 | v.Highlight = false 53 | v.Wrap = true 54 | config.ViewConfig(v) 55 | 56 | return confirmed, cleanUp, nil 57 | } 58 | 59 | func FitMessageWithOkButton(gui *gocui.Gui, msg string, cfgs ...func(*gocui.View)) Config { 60 | w, h := gui.Size() 61 | msgLen := utf8.RuneCountInString(msg) 62 | vw, vh := int(math.Max(float64(msgLen+2), 5)), 3 63 | if maxWidth := int(float64(w)*0.7); msgLen > maxWidth { 64 | vw = maxWidth 65 | vh = int(math.Ceil(float64(msgLen) / float64(maxWidth - 2.0))) + 2 66 | } 67 | x0, y0 := w/2-vw/2, h/2-vh/2 68 | x1, y1 := x0+vw, y0+vh 69 | return Config{ 70 | gui, 71 | Pos{x0, y0, x1, y1}, 72 | func(v *gocui.View) { 73 | for _, cfg := range cfgs { 74 | cfg(v) 75 | } 76 | 77 | fmt.Fprintln(v, msg) 78 | filler := strings.Repeat(" ", vw/2-2) 79 | fmt.Fprint(v, filler, ">OK<", filler) 80 | 81 | v.SetCursor(0, vh-2) 82 | v.Highlight = true 83 | v.Wrap = true 84 | v.SelBgColor = gocui.ColorGreen 85 | v.SelFgColor = gocui.ColorBlack 86 | }} 87 | } 88 | -------------------------------------------------------------------------------- /dialog/stuff_loader.go: -------------------------------------------------------------------------------- 1 | package dialog 2 | 3 | import ( 4 | "github.com/jroimartin/gocui" 5 | "fmt" 6 | "unicode/utf8" 7 | ) 8 | 9 | const stuffLoaderViewName = "stuffLoaderViewName" 10 | 11 | func StuffLoader(config Config, f func()) (<-chan bool, error) { 12 | cleanUp := cleanUpFunc(config.Gui, stuffLoaderViewName) 13 | v, err := config.Gui.SetView(stuffLoaderViewName, config.X0, config.Y0, config.X1, config.Y1) 14 | if err == gocui.ErrUnknownView { 15 | err = nil 16 | } else if err != nil { 17 | return nil, err 18 | } 19 | 20 | jobDone := make(chan bool) 21 | 22 | v.Editor = gocui.EditorFunc(func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 23 | switch { 24 | case key == gocui.KeyCtrlQ || key == gocui.KeyEsc || ch == 'q': 25 | jobDone <- false 26 | config.Gui.Update(cleanUp) 27 | } 28 | }) 29 | 30 | config.Gui.SetCurrentView(stuffLoaderViewName) 31 | config.Gui.SetViewOnTop(stuffLoaderViewName) 32 | defaultViewConfig(v) 33 | config.ViewConfig(v) 34 | 35 | go func() { 36 | f() 37 | jobDone <- true 38 | config.Gui.Update(cleanUp) 39 | }() 40 | 41 | return jobDone, nil 42 | } 43 | 44 | func FitMessage(gui *gocui.Gui, msg string, cfgs ...func(*gocui.View)) Config { 45 | w, h := gui.Size() 46 | vw, vh := utf8.RuneCountInString(msg)+1, 2 47 | x0, y0 := w/2-vw/2, h/2-vh/2 48 | x1, y1 := x0+vw, y0+vh 49 | return Config{ 50 | gui, 51 | Pos{x0, y0, x1, y1}, 52 | func(v *gocui.View) { 53 | for _, cfg := range cfgs { 54 | cfg(v) 55 | } 56 | Msg(msg)(v) 57 | }} 58 | } 59 | 60 | func defaultViewConfig(v *gocui.View) { 61 | v.Editable = true 62 | v.Highlight = true 63 | v.Wrap = true 64 | } 65 | 66 | func Msg(msg string) func(*gocui.View) { 67 | return func(v *gocui.View) { 68 | fmt.Fprintln(v, msg) 69 | } 70 | } 71 | 72 | func Title(title string) func(*gocui.View) { 73 | return func(v *gocui.View) { 74 | v.Title = title 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /fuzzyselect.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "github.com/aqatl/mal/anilist" 7 | "github.com/aqatl/mal/mal" 8 | "github.com/fatih/color" 9 | "github.com/jroimartin/gocui" 10 | "github.com/sahilm/fuzzy" 11 | "github.com/urfave/cli" 12 | "strings" 13 | ) 14 | 15 | func fuzzySelectEntry(ctx *cli.Context) error { 16 | _, list, err := loadMAL(ctx) 17 | if err != nil { 18 | return err 19 | } 20 | cfg := LoadConfig() 21 | 22 | displayData := make([]string, len(list)) 23 | for i := range list { 24 | displayData[i] = list[i].Title 25 | } 26 | 27 | searchData := make([]string, len(list)) 28 | for i, entry := range list { 29 | searchData[i] = strings.ToLower(fmt.Sprintf("%s %s", 30 | entry.Title, 31 | strings.Replace(entry.Synonyms, ";", "", -1))) 32 | } 33 | 34 | fsc := &fuzzySelCui{ 35 | DisplayData: displayData, 36 | SearchData: searchData, 37 | MatchIdx: -1, 38 | } 39 | 40 | initSearch := strings.Join(ctx.Args(), " ") 41 | 42 | if ctx.NArg() != 0 { 43 | fsc.Matches = fuzzy.Find(initSearch, fsc.SearchData) 44 | if matchesLen := len(fsc.Matches); matchesLen == 0 { 45 | return fmt.Errorf("no match found") 46 | } else if matchesLen == 1 { 47 | saveSelection(cfg, list[fsc.Matches[0].Index]) 48 | return nil 49 | } 50 | } 51 | 52 | if err = startFuzzySelectCUI(fsc, initSearch); err != nil || fsc.MatchIdx == -1 { 53 | return err 54 | } 55 | saveSelection(cfg, list[fsc.MatchIdx]) 56 | 57 | return nil 58 | } 59 | 60 | func alFuzzySelectEntry(ctx *cli.Context) error { 61 | al, err := loadAniList(ctx) 62 | if err != nil { 63 | return err 64 | } 65 | cfg := LoadConfig() 66 | list := alGetList(al, anilist.All) 67 | 68 | displayData := make([]string, len(list)) 69 | for i := range list { 70 | displayData[i] = list[i].Title.UserPreferred 71 | } 72 | 73 | searchData := make([]string, len(list)) 74 | for i, entry := range list { 75 | searchData[i] = strings.ToLower(entry.Title.Romaji + 76 | entry.Title.English + 77 | entry.Title.Native + 78 | strings.Join(entry.Synonyms, " ")) 79 | } 80 | 81 | fsc := &fuzzySelCui{ 82 | DisplayData: displayData, 83 | SearchData: searchData, 84 | MatchIdx: -1, 85 | } 86 | 87 | initSearch := strings.Join(ctx.Args(), " ") 88 | 89 | if ctx.NArg() != 0 { 90 | fsc.Matches = fuzzy.Find(initSearch, fsc.SearchData) 91 | if matchesLen := len(fsc.Matches); matchesLen == 0 { 92 | return fmt.Errorf("no match found") 93 | } else if matchesLen == 1 { 94 | alSaveSelection(cfg, &list[fsc.Matches[0].Index], al.User.MediaListOptions.ScoreFormat) 95 | return nil 96 | } 97 | } 98 | 99 | if err = startFuzzySelectCUI(fsc, initSearch); err != nil || fsc.MatchIdx == -1 { 100 | return err 101 | } 102 | alSaveSelection(cfg, &list[fsc.MatchIdx], al.User.MediaListOptions.ScoreFormat) 103 | 104 | return nil 105 | } 106 | 107 | func startFuzzySelectCUI(fsc *fuzzySelCui, initSearch string) error { 108 | gui, err := gocui.NewGui(gocui.OutputNormal) 109 | if err != nil { 110 | return fmt.Errorf("gocui error: %v", err) 111 | } 112 | 113 | gui.SetManager(fsc) 114 | fsc.setGuiKeyBindings(gui) 115 | 116 | gui.Cursor = true 117 | gui.Mouse = false 118 | gui.Highlight = true 119 | gui.SelFgColor = gocui.ColorGreen 120 | 121 | fsc.Layout(gui) 122 | fsc.InputView.Write([]byte(initSearch)) 123 | fsc.InputView.Editor.Edit(fsc.InputView, gocui.KeyBackspace, 0, gocui.ModNone) 124 | fsc.InputView.MoveCursor(len(initSearch), 0, true) 125 | 126 | err = gui.MainLoop() 127 | gui.Close() 128 | if err == gocui.ErrQuit { 129 | err = nil 130 | } 131 | return err 132 | } 133 | 134 | func saveSelection(cfg *Config, entry *mal.Anime) { 135 | cfg.SelectedID = entry.ID 136 | cfg.Save() 137 | 138 | fmt.Println("Selected entry:") 139 | malPrintEntryDetails(entry) 140 | } 141 | 142 | const ( 143 | FsgInputView = "fsgInputView" 144 | FsgOutputView = "fsgOutputView" 145 | FsgShortcutsView = "fsgShortcutsView" 146 | ) 147 | 148 | type fuzzySelCui struct { 149 | DisplayData []string 150 | SearchData []string 151 | 152 | Matches []fuzzy.Match 153 | MatchIdx int 154 | 155 | InputView, OutputView *gocui.View 156 | } 157 | 158 | func (fsc *fuzzySelCui) Layout(gui *gocui.Gui) error { 159 | w, h := gui.Size() 160 | if v, err := gui.SetView(FsgInputView, 0, 0, w-1, 2); err != nil { 161 | if err != gocui.ErrUnknownView { 162 | return err 163 | } 164 | 165 | v.Title = "Input" 166 | v.Editor = gocui.EditorFunc(fsc.InputViewEditor) 167 | v.Editable = true 168 | v.Wrap = true 169 | 170 | gui.SetCurrentView(FsgInputView) 171 | fsc.InputView = v 172 | } 173 | 174 | if v, err := gui.SetView(FsgOutputView, 0, 3, w-1, h-4); err != nil { 175 | if err != gocui.ErrUnknownView { 176 | return err 177 | } 178 | 179 | v.Title = "Found entries" 180 | v.SelBgColor = gocui.ColorGreen 181 | v.SelFgColor = gocui.ColorBlack 182 | v.Highlight = true 183 | v.Editable = true 184 | v.Editor = gocui.EditorFunc(fsc.OutputViewEditor) 185 | 186 | fsc.OutputView = v 187 | } 188 | 189 | if v, err := gui.SetView(FsgShortcutsView, 0, h-3, w-1, h-1); err != nil { 190 | if err != gocui.ErrUnknownView { 191 | return err 192 | } 193 | 194 | v.Title = "Shortcuts" 195 | v.Editable = false 196 | 197 | fmt.Fprintf(v, "Ctrl+C: quit | Tab: switch window | Enter: select highlighted entry") 198 | } 199 | 200 | return nil 201 | } 202 | 203 | var highlighter = color.New(color.FgBlack, color.BgWhite).FprintFunc() 204 | 205 | func (fsc *fuzzySelCui) InputViewEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 206 | if key == gocui.KeyArrowUp || key == gocui.KeyArrowDown { 207 | fsc.OutputViewEditor(fsc.OutputView, key, ch, mod) 208 | return 209 | } 210 | gocui.DefaultEditor.Edit(v, key, ch, mod) 211 | 212 | fsc.OutputView.Clear() 213 | 214 | pattern := strings.TrimSpace(v.Buffer()) 215 | fsc.Matches = fuzzy.Find(pattern, fsc.SearchData) 216 | 217 | buf := bufio.NewWriter(fsc.OutputView) 218 | 219 | for _, match := range fsc.Matches { 220 | mIdx := 0 221 | for i, r := range []rune(fsc.DisplayData[match.Index]) { 222 | if mIdx < len(match.MatchedIndexes) && i == match.MatchedIndexes[mIdx] { 223 | mIdx++ 224 | highlighter(buf, string(r)) 225 | } else { 226 | buf.WriteRune(r) 227 | } 228 | } 229 | buf.WriteRune('\n') 230 | } 231 | buf.Flush() 232 | 233 | fsc.OutputView.SetCursor(0, 0) 234 | } 235 | 236 | func (fsc *fuzzySelCui) OutputViewEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 237 | switch { 238 | case key == gocui.KeyArrowDown || ch == 'j': 239 | v.MoveCursor(0, 1, false) 240 | case key == gocui.KeyArrowUp || ch == 'k': 241 | v.MoveCursor(0, -1, false) 242 | } 243 | } 244 | 245 | func (fsc *fuzzySelCui) setGuiKeyBindings(gui *gocui.Gui) { 246 | quit := func(gui *gocui.Gui, v *gocui.View) error { 247 | return gocui.ErrQuit 248 | } 249 | gui.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit) 250 | gui.SetKeybinding("", gocui.KeyCtrlQ, gocui.ModNone, quit) 251 | 252 | gui.SetKeybinding("", gocui.KeyTab, gocui.ModNone, func(gui *gocui.Gui, v *gocui.View) error { 253 | switch v.Name() { 254 | case FsgInputView: 255 | gui.SetCurrentView(FsgOutputView) 256 | case FsgOutputView: 257 | gui.SetCurrentView(FsgInputView) 258 | } 259 | return nil 260 | }) 261 | 262 | gui.SetKeybinding("", gocui.KeyEnter, gocui.ModNone, func(gui *gocui.Gui, v *gocui.View) error { 263 | _, y := fsc.OutputView.Cursor() 264 | _, oy := fsc.OutputView.Origin() 265 | y += oy 266 | if ml := len(fsc.Matches); ml == 0 || y > ml-1 || y < 0 { 267 | return nil 268 | } 269 | 270 | fsc.MatchIdx = fsc.Matches[y].Index 271 | 272 | return gocui.ErrQuit 273 | }) 274 | } 275 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aqatl/mal 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/PuerkitoBio/goquery v1.7.0 7 | github.com/aqatl/cliwait v0.0.0-20180719204814-741dc231a3b4 8 | github.com/atotto/clipboard v0.1.4 9 | github.com/fatih/color v1.12.0 10 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b 11 | github.com/jroimartin/gocui v0.4.0 12 | github.com/kylelemons/godebug v1.1.0 // indirect 13 | github.com/nsf/termbox-go v1.1.1 // indirect 14 | github.com/pkg/errors v0.9.1 15 | github.com/sahilm/fuzzy v0.1.0 16 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 17 | github.com/urfave/cli v1.22.5 18 | golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/PuerkitoBio/goquery v1.7.0 h1:O5SP3b9JWqMSVMG69zMfj577zwkSNpxrFf7ybS74eiw= 3 | github.com/PuerkitoBio/goquery v1.7.0/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= 4 | github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= 5 | github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= 6 | github.com/aqatl/cliwait v0.0.0-20180719204814-741dc231a3b4 h1:9lSWnlwG15XfCG7WaY2E3RC9+Ublq06dNdE/eJvjy8M= 7 | github.com/aqatl/cliwait v0.0.0-20180719204814-741dc231a3b4/go.mod h1:SnyiOUVlw7b9a4wFcQ5o+6w/mZ5iEZNZfvGbreiPZAw= 8 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 9 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 12 | github.com/fatih/color v1.12.0 h1:mRhaKNwANqRgUBGKmnI5ZxEk7QXmjQeCcuYFMX2bfcc= 13 | github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= 14 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= 15 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= 16 | github.com/jroimartin/gocui v0.4.0 h1:52jnalstgmc25FmtGcWqa0tcbMEWS6RpFLsOIO+I+E8= 17 | github.com/jroimartin/gocui v0.4.0/go.mod h1:7i7bbj99OgFHzo7kB2zPb8pXLqMBSQegY7azfqXMkyY= 18 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 19 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 20 | github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= 21 | github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 22 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 23 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 24 | github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= 25 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 26 | github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= 27 | github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= 28 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 29 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 33 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 34 | github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= 35 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 36 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 37 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 38 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= 39 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= 40 | github.com/urfave/cli v1.22.5 h1:lNq9sAHXK2qfdI8W+GRItjCEkI+2oR4d+MEHy1CKXoU= 41 | github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 42 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 43 | golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e h1:gsTQYXdTw2Gq7RBsWvlQ91b+aEQ6bXFUngBGuR8sPpI= 44 | golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 45 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 46 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 47 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= 48 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 49 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 50 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 51 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 52 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 53 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= 54 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= 56 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 57 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 58 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 59 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 60 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 61 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 62 | -------------------------------------------------------------------------------- /gobuildall.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/pwsh 2 | 3 | New-Item -ItemType Directory -Force -Path @( 4 | "build/windows_x64", 5 | "build/windows_x86", 6 | "build/linux_x64", 7 | "build/linux_x86", 8 | "build/linux_arm64", 9 | "build/darwin_x64", 10 | "build/darwin_x86", 11 | "build/darwin_arm64" 12 | ) | Out-Null 13 | 14 | $rootDir = Get-Location 15 | Write-Host "rootDir: $rootDir" 16 | 17 | if ($IsWindows) { 18 | $script:GoExe = "go.exe" 19 | } else { 20 | $script:GoExe = "go" 21 | } 22 | 23 | class BuildTarget { 24 | [String]$OS 25 | [String]$Arch 26 | [String]$BuildDir 27 | 28 | BuildTarget( 29 | [String]$OS, 30 | [String]$Arch, 31 | [String]$BuildDir 32 | ) { 33 | $this.OS = $OS 34 | $this.Arch = $Arch 35 | $this.BuildDir = $BuildDir 36 | } 37 | } 38 | 39 | $targets = @( 40 | [BuildTarget]::new("linux", "amd64", "linux_x64"), 41 | [BuildTarget]::new("linux", "386", "linux_x86"), 42 | [BuildTarget]::new("linux", "arm64", "linux_arm64"), 43 | [BuildTarget]::new("windows", "amd64", "windows_x64"), 44 | [BuildTarget]::new("windows", "386", "windows_x86"), 45 | [BuildTarget]::new("darwin", "amd64", "darwin_x64"), 46 | [BuildTarget]::new("darwin", "amd64", "darwin_x86"), 47 | [BuildTarget]::new("darwin", "arm64", "darwin_arm64") 48 | ) 49 | 50 | $jobs = @() 51 | 52 | $targets | ForEach-Object { 53 | $jobs += Start-ThreadJob ` 54 | -Name $_.BuildDir ` 55 | -StreamingHost $Host ` 56 | -ScriptBlock { 57 | $item = $using:_ 58 | $OS = $item.OS 59 | $Arch = $item.Arch 60 | $BuildDir = $item.BuildDir 61 | $rootDir = $using:rootdir 62 | $GoExe = $using:GoExe 63 | 64 | Write-Host "Building for $OS $ARCH" 65 | 66 | Set-Location $rootDir\build\$BuildDir 67 | 68 | New-Item -Path Env:\GOOS -Value $OS -Force | Out-Null 69 | New-Item -Path Env:\GOARCH -Value $ARCH -Force | Out-Null 70 | & $GoExe build ../.. 71 | 72 | Write-Host "Compressing $BuildDir" 73 | 74 | Set-Location $rootDir\build 75 | Compress-Archive ` 76 | -Path (Get-ChildItem .\$BuildDir\*) ` 77 | -DestinationPath "${BuildDir}.zip" ` 78 | -CompressionLevel Optimal 79 | } 80 | } 81 | 82 | Wait-Job -Job $jobs 83 | 84 | -------------------------------------------------------------------------------- /gobuildall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | mkdir -p build/windows_x64 \ 4 | build/windows_x86 \ 5 | build/linux_x64 \ 6 | build/linux_x86 \ 7 | build/darwin_x64 \ 8 | build/darwin_x86 \ 9 | build/darwin_arm64 10 | 11 | rootDir=`pwd` 12 | 13 | if [ "$(uname -s)" = "Windows" ]; then 14 | GO_EXE=go.exe 15 | else 16 | GO_EXE=go 17 | fi 18 | 19 | 20 | echo Building for Linux amd64 21 | cd $rootDir/build/linux_x64 22 | GOOS=linux GOARCH=amd64 $GO_EXE build ../.. & 23 | 24 | echo Building for Linux 386 25 | cd $rootDir/build/linux_x86 26 | GOOS=linux GOARCH=386 GO386=softfloat $GO_EXE build ../.. & 27 | 28 | echo Building for Windows amd64 29 | cd $rootDir/build/windows_x64 30 | GOOS=windows GOARCH=amd64 $GO_EXE build ../.. & 31 | 32 | echo Building for Windows 386 33 | cd $rootDir/build/windows_x86 34 | GOOS=windows GOARCH=386 GO386=softfloat $GO_EXE build ../.. & 35 | 36 | echo Building for Darwin amd64 37 | cd $rootDir/build/darwin_x64 38 | GOOS=darwin GOARCH=amd64 $GO_EXE build ../.. & 39 | 40 | echo Building for Darwin 386 41 | cd $rootDir/build/darwin_x86 42 | GOOS=darwin GOARCH=386 GO386=softfloat $GO_EXE build ../.. & 43 | 44 | echo Building for Darwin arm64 45 | cd $rootDir/build/darwin_arm64 46 | GOOS=darwin GOARCH=arm64 $GO_EXE build ../.. & 47 | 48 | wait 49 | 50 | echo Done building 51 | 52 | cd $rootDir/build 53 | 54 | for dir in */; do 55 | dir=${dir:0:(-1)} 56 | echo "Compressing $dir" 57 | 7z a -t7z -m0=lzma -mx=9 -mfb=64 -md=32m -ms=on $dir.7z ./$dir/* > /dev/null & 58 | 7z a -mx=9 -mfb=64 $dir.zip ./$dir/* > /dev/null & 59 | done 60 | 61 | wait 62 | 63 | echo Done compressing 64 | 65 | cd $rootDir 66 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/fatih/color" 6 | "github.com/urfave/cli" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | var dataDir = getDataDir() 13 | var ( 14 | AppConfigFile = filepath.Join(dataDir, "appConfig.json") 15 | 16 | MalCredentialsFile = filepath.Join(dataDir, "malCred.dat") 17 | MalCacheFile = filepath.Join(dataDir, "malCache.xml") 18 | MalStatsCacheFile = filepath.Join(dataDir, "malStats.xml") 19 | MalConfigFile = filepath.Join(dataDir, "malConfig.json") 20 | 21 | AniListCredsFile = filepath.Join(dataDir, "aniListCreds.json") 22 | AniListUserFile = filepath.Join(dataDir, "aniListUser.json") 23 | AniListCacheFile = filepath.Join(dataDir, "aniListCache.json") 24 | ) 25 | 26 | type Mode uint 27 | 28 | const ( 29 | MalMode Mode = iota 30 | AniListMode 31 | ) 32 | 33 | type AppConfig struct { 34 | Mode Mode 35 | } 36 | 37 | func main() { 38 | checkDataDir() 39 | 40 | appCfg := AppConfig{Mode: AniListMode} 41 | LoadJsonFile(AppConfigFile, &appCfg) 42 | 43 | app := cli.NewApp() 44 | app.Name = "mal" 45 | app.Usage = "App for managing your MAL" 46 | app.Version = "4ever in beta" 47 | 48 | switch appCfg.Mode { 49 | case MalMode: 50 | runApp(MalApp(app)) 51 | case AniListMode: 52 | runApp(AniListApp(app)) 53 | } 54 | } 55 | 56 | func checkDataDir() { 57 | if err := os.Mkdir(dataDir, os.ModePerm); err == nil { 58 | log.Printf("Created cache directory at %s", dataDir) 59 | } else if !os.IsExist(err) { 60 | log.Printf("Error creating cache directory (%s): %v", dataDir, err) 61 | } 62 | } 63 | 64 | func runApp(app *cli.App) { 65 | if err := app.Run(os.Args); err != nil { 66 | exitWithError(err) 67 | } 68 | } 69 | 70 | func exitWithError(err error) { 71 | fmt.Fprintf(color.Output, "Error: %v\n", err) 72 | os.Exit(1) 73 | } 74 | -------------------------------------------------------------------------------- /mal/anime.go: -------------------------------------------------------------------------------- 1 | package mal 2 | 3 | import ( 4 | "sort" 5 | ) 6 | 7 | type AnimeType int 8 | 9 | const ( 10 | Tv AnimeType = iota + 1 11 | Ova 12 | Movie 13 | Special 14 | Ona 15 | Music 16 | ) 17 | 18 | func (t AnimeType) String() string { 19 | types := [...]string{ 20 | Tv: "TV", 21 | Ova: "OVA", 22 | Movie: "Movie", 23 | Special: "Special", 24 | Ona: "ONA", 25 | Music: "Music", 26 | } 27 | if t < 1 || int(t) >= len(types) { 28 | return "" 29 | } 30 | return types[t] 31 | } 32 | 33 | type AnimeStatus int 34 | 35 | const ( 36 | CurrentlyAiring AnimeStatus = iota + 1 37 | FinishedAiring 38 | NotYetAired 39 | ) 40 | 41 | func (status AnimeStatus) String() string { 42 | names := [...]string{ 43 | CurrentlyAiring: "CurrentlyAiring", 44 | FinishedAiring: "FinishedAiring", 45 | NotYetAired: "NotYetAired", 46 | } 47 | if status < 1 || int(status) >= len(names) { 48 | return "" 49 | } 50 | return names[status] 51 | } 52 | 53 | type AnimeScore int 54 | 55 | const ( 56 | NotRatedYet AnimeScore = iota 57 | Appalling 58 | Horrible 59 | VeryBad 60 | Bad 61 | Average 62 | Fine 63 | Good 64 | VeryGood 65 | Great 66 | Masterpiece 67 | ) 68 | 69 | type MyStatus int 70 | 71 | const ( 72 | All MyStatus = iota 73 | Watching 74 | Completed 75 | OnHold 76 | Dropped 77 | PlanToWatch MyStatus = 6 //Apparently MAL stores this as 6 78 | ) 79 | 80 | func (status MyStatus) String() string { 81 | names := [...]string{ 82 | All: "All", 83 | Watching: "Watching", 84 | Completed: "Completed", 85 | OnHold: "OnHold", 86 | Dropped: "Dropped", 87 | 5: "", 88 | PlanToWatch: "PlanToWatch", 89 | } 90 | if status < 0 || int(status) >= len(names) { 91 | return "" 92 | } 93 | return names[status] 94 | } 95 | 96 | type Anime struct { 97 | ID int `xml:"series_animedb_id"` 98 | Title string `xml:"series_title"` 99 | Synonyms string `xml:"series_synonyms"` 100 | Type AnimeType `xml:"series_type"` 101 | Episodes int `xml:"series_episodes"` 102 | Status AnimeStatus `xml:"series_status"` 103 | SeriesStart string `xml:"series_start"` 104 | SeriesEnd string `xml:"series_end"` 105 | ImageURL string `xml:"series_image"` 106 | 107 | MyID int `xml:"my_id"` 108 | WatchedEpisodes int `xml:"my_watched_episodes"` 109 | MyStart string `xml:"my_start_date"` 110 | MyFinish string `xml:"my_finish_date"` 111 | MyScore AnimeScore `xml:"my_score"` 112 | MyStatus MyStatus `xml:"my_status"` 113 | MyRewatching int `xml:"my_rewatching"` 114 | MyRewatchingEpisode int `xml:"my_rewatching_ep"` 115 | LastUpdated int64 `xml:"my_last_updated"` 116 | MyTags string `xml:"my_tags"` 117 | } 118 | 119 | type AnimeDetails struct { 120 | JapaneseTitle string 121 | Related []Related 122 | Synopsis string 123 | Background string 124 | Characters []Character 125 | Staff []Staff 126 | OpeningThemes []string 127 | EndingThemes []string 128 | Premiered string 129 | Broadcast string 130 | Producers []string 131 | Licensors []string 132 | Studios []string 133 | Source string 134 | Genres []string 135 | Duration string 136 | Rating string 137 | Score float64 138 | ScoreVoters int 139 | Ranked int 140 | Popularity int 141 | Members int 142 | Favorites int 143 | } 144 | 145 | type Character struct { 146 | Name string 147 | Role string 148 | VoiceActor string 149 | VoiceActorOrigin string 150 | } 151 | 152 | type Staff struct { 153 | Name string 154 | Position string 155 | } 156 | 157 | type Related struct { 158 | Relation string 159 | Title string 160 | Url string 161 | } 162 | 163 | const AnimeXMLTemplate = ` 164 | 165 | {{.WatchedEpisodes}} 166 | {{ printf "%d" .MyStatus }} 167 | {{.MyScore}} 168 | {{.MyRewatching}} 169 | {{.MyRewatchingEpisode}} 170 | {{.MyStart}} 171 | {{.MyFinish}} 172 | {{.MyTags}} 173 | ` 174 | 175 | type AnimeCustomSort struct { 176 | List []*Anime 177 | LessF func(x, y *Anime) bool 178 | } 179 | 180 | func (acs AnimeCustomSort) Len() int { 181 | return len(acs.List) 182 | } 183 | 184 | func (acs AnimeCustomSort) Less(i, j int) bool { 185 | return acs.LessF(acs.List[i], acs.List[j]) 186 | } 187 | 188 | func (acs AnimeCustomSort) Swap(i, j int) { 189 | acs.List[i], acs.List[j] = acs.List[j], acs.List[i] 190 | } 191 | 192 | func AnimeSortByLastUpdated(list []*Anime) sort.Interface { 193 | return AnimeCustomSort{list, func(x, y *Anime) bool { 194 | return x.LastUpdated > y.LastUpdated 195 | }} 196 | } 197 | 198 | func AnimeSortByTitle(list []*Anime) sort.Interface { 199 | return AnimeCustomSort{list, func(x, y *Anime) bool { 200 | return x.Title < y.Title 201 | }} 202 | } 203 | 204 | func AnimeSortByWatchedEpisodes(list []*Anime) sort.Interface { 205 | return AnimeCustomSort{list, func(x, y *Anime) bool { 206 | return x.WatchedEpisodes < y.WatchedEpisodes 207 | }} 208 | } 209 | 210 | func AnimeSortByScore(list []*Anime) sort.Interface { 211 | return AnimeCustomSort{list, func(x, y *Anime) bool { 212 | return x.MyScore < y.MyScore 213 | }} 214 | } 215 | -------------------------------------------------------------------------------- /mal/animelist.go: -------------------------------------------------------------------------------- 1 | package mal 2 | 3 | type AnimeList []*Anime 4 | 5 | func (list AnimeList) GetByID(id int) *Anime { 6 | for _, entry := range list { 7 | if entry.ID == id { 8 | return entry 9 | } 10 | } 11 | return nil 12 | } 13 | 14 | func (list AnimeList) FilterByStatus(status MyStatus) AnimeList { 15 | filter := func(vs AnimeList, f func(anime *Anime) bool) AnimeList { 16 | vsf := make(AnimeList, 0) 17 | for _, a := range vs { 18 | if f(a) { 19 | vsf = append(vsf, a) 20 | } 21 | } 22 | return vsf 23 | } 24 | 25 | if status == All { 26 | return list 27 | } else { 28 | return filter(list, func(anime *Anime) bool { return anime.MyStatus == status }) 29 | } 30 | } 31 | 32 | func (list AnimeList) DeleteByID(id int) AnimeList { 33 | idx := 0 34 | found := false 35 | for i, entry := range list { 36 | if entry.ID == id { 37 | idx = i 38 | found = true 39 | break 40 | } 41 | } 42 | if !found { 43 | return list 44 | } 45 | 46 | copy(list[idx:], list[idx+1:]) 47 | list[len(list)-1] = nil 48 | list = list[:len(list)-1] 49 | 50 | return list 51 | } -------------------------------------------------------------------------------- /mal/animeparser.go: -------------------------------------------------------------------------------- 1 | package mal 2 | 3 | import ( 4 | "github.com/PuerkitoBio/goquery" 5 | "log" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | func parseRating(spanDarkText *goquery.Selection) string { 11 | return strings.TrimSpace(spanDarkText. 12 | FilterFunction(isTextEqualFilterFunc("Rating:")). 13 | Nodes[0]. 14 | NextSibling. 15 | Data) 16 | } 17 | 18 | func parseDuration(spanDarkText *goquery.Selection) string { 19 | return strings.TrimSpace(spanDarkText. 20 | FilterFunction(isTextEqualFilterFunc("Duration:")). 21 | Nodes[0]. 22 | NextSibling. 23 | Data) 24 | } 25 | 26 | func parseGenres(spanDarkText *goquery.Selection) []string { 27 | genres := make([]string, 0) 28 | spanDarkText.FilterFunction(isTextEqualFilterFunc("Genres:")). 29 | Siblings(). 30 | Each(func(i int, s *goquery.Selection) { 31 | genres = append(genres, s.Text()) 32 | }) 33 | return genres 34 | } 35 | 36 | func parseSource(spanDarkText *goquery.Selection) string { 37 | return strings.TrimSpace(spanDarkText. 38 | FilterFunction(isTextEqualFilterFunc("Source:")). 39 | Nodes[0]. 40 | NextSibling. 41 | Data) 42 | } 43 | 44 | func parseStudios(spanDarkText *goquery.Selection) []string { 45 | studios := make([]string, 0) 46 | 47 | spanDarkText.FilterFunction(isTextEqualFilterFunc("Studios:")). 48 | Siblings(). 49 | Each(func(i int, s *goquery.Selection) { 50 | studios = append(studios, s.Text()) 51 | }) 52 | 53 | return studios 54 | } 55 | 56 | func parseLicensors(spanDarkText *goquery.Selection) []string { 57 | licensors := make([]string, 0) 58 | 59 | spanDarkText.FilterFunction(isTextEqualFilterFunc("Licensors:")). 60 | Siblings(). 61 | Each(func(i int, s *goquery.Selection) { 62 | licensors = append(licensors, s.Text()) 63 | }) 64 | 65 | return licensors 66 | } 67 | 68 | func parseProducers(spanDarkText *goquery.Selection) []string { 69 | producers := make([]string, 0) 70 | 71 | spanDarkText.FilterFunction(isTextEqualFilterFunc("Producers:")). 72 | Siblings(). 73 | Each(func(i int, s *goquery.Selection) { 74 | producers = append(producers, s.Text()) 75 | }) 76 | 77 | return producers 78 | } 79 | 80 | func parseBroadcast(spanDarkText *goquery.Selection) string { 81 | nodes := spanDarkText. 82 | FilterFunction(isTextEqualFilterFunc("Broadcast:")). 83 | Nodes 84 | if len(nodes) == 0 { 85 | return "" 86 | } 87 | return strings.TrimSpace(nodes[0].NextSibling.Data) 88 | } 89 | 90 | func parsePremiered(spanDarkText *goquery.Selection) string { 91 | return spanDarkText. 92 | FilterFunction(isTextEqualFilterFunc("Premiered:")). 93 | Next(). 94 | Text() 95 | } 96 | 97 | func parseBackground(synopsisNode *goquery.Selection) string { 98 | return "Not implemented yet" 99 | } 100 | 101 | func parseSynopsis(synopsisNode *goquery.Selection) string { 102 | return synopsisNode.Text() 103 | } 104 | 105 | func parseJapaneseTitle(reader *goquery.Document) string { 106 | return strings.TrimSpace(reader.Find("div .spaceit_pad span"). 107 | FilterFunction(isTextEqualFilterFunc("Japanese:")). 108 | Nodes[0]. 109 | NextSibling. 110 | Data) 111 | } 112 | 113 | func parseRelated(reader *goquery.Document) []Related { 114 | relateds := make([]Related, 0) 115 | 116 | reader.Selection.Find(".anime_detail_related_anime tr").Each( 117 | func(i int, s *goquery.Selection) { 118 | relation := s.Find("td").First().Text() 119 | relation = relation[:len(relation)-1] 120 | 121 | link := s.Find("a") 122 | title := link.Text() 123 | url, _ := link.Attr("href") 124 | url = BaseMALAddress + url 125 | 126 | related := Related{relation, title, url} 127 | relateds = append(relateds, related) 128 | }) 129 | 130 | return relateds 131 | } 132 | 133 | func parseCharacters(reader *goquery.Document) []Character { 134 | characters := make([]Character, 0) 135 | 136 | reader.Selection. 137 | Find("div .detail-characters-list"). 138 | First(). 139 | Find("table"). 140 | FilterFunction(func(i int, s *goquery.Selection) bool { 141 | return i%2 == 0 142 | }). 143 | Each(func(i int, s *goquery.Selection) { 144 | c := Character{} 145 | tdNodes := s.Find("td").Next() 146 | 147 | names := [2]string{} 148 | tdNodes.Find("a").Each(func(i int, s *goquery.Selection) { 149 | if i < len(names) { 150 | names[i] = strings.TrimSpace(s.Text()) 151 | } 152 | }) 153 | c.Name = names[0] 154 | c.VoiceActor = names[1] 155 | 156 | roleAndActorOrigin := [2]string{} 157 | tdNodes.Find("small").Each(func(i int, s *goquery.Selection) { 158 | if i < len(roleAndActorOrigin) { 159 | roleAndActorOrigin[i] = strings.TrimSpace(s.Text()) 160 | } 161 | }) 162 | c.Role = roleAndActorOrigin[0] 163 | c.VoiceActorOrigin = roleAndActorOrigin[1] 164 | 165 | characters = append(characters, c) 166 | }) 167 | 168 | return characters 169 | } 170 | 171 | func parseScore(spanDarkText *goquery.Selection) float64 { 172 | score, err := strconv.ParseFloat( 173 | spanDarkText.FilterFunction(isTextEqualFilterFunc("Score:")). 174 | Next(). 175 | Text(), 176 | 64) 177 | if err != nil { 178 | log.Printf("error parsing score: %v", err) 179 | return 0 180 | } 181 | 182 | return score 183 | } 184 | 185 | func parseScoreVoters(reader *goquery.Document) int { 186 | voters, err := strconv.Atoi(strings.Replace( 187 | reader.Selection. 188 | Find("span[itemprop=ratingCount]").Nodes[0].FirstChild.Data, 189 | ",", 190 | "", 191 | -1)) 192 | if err != nil { 193 | log.Printf("error parsing ScoreVoters: %v", err) 194 | return 0 195 | } 196 | 197 | return voters 198 | } 199 | 200 | func parseRanked(spanDarkText *goquery.Selection) int { 201 | ranked, err := strconv.Atoi(strings.TrimPrefix(strings.TrimSpace( 202 | spanDarkText. 203 | FilterFunction(isTextEqualFilterFunc("Ranked:")). 204 | Nodes[0]. 205 | NextSibling. 206 | Data), 207 | "#")) 208 | if err != nil { 209 | log.Printf("error parsing Ranked: %v", err) 210 | return 0 211 | } 212 | 213 | return ranked 214 | } 215 | 216 | func parsePopularity(spanDarkText *goquery.Selection) int { 217 | popularity, err := strconv.Atoi(strings.TrimPrefix(strings.TrimSpace( 218 | spanDarkText. 219 | FilterFunction(isTextEqualFilterFunc("Popularity:")). 220 | Nodes[0]. 221 | NextSibling. 222 | Data), 223 | "#")) 224 | if err != nil { 225 | log.Printf("error parsing Popularity: %v", err) 226 | return 0 227 | } 228 | 229 | return popularity 230 | } 231 | 232 | func parseMembers(spanDarkText *goquery.Selection) int { 233 | members, err := strconv.Atoi(strings.Replace(strings.TrimSpace( 234 | spanDarkText.FilterFunction(isTextEqualFilterFunc("Members:")). 235 | Nodes[0]. 236 | NextSibling. 237 | Data), 238 | ",", 239 | "", 240 | -1)) 241 | if err != nil { 242 | log.Printf("error parsing Members: %v", err) 243 | return 0 244 | } 245 | 246 | return members 247 | } 248 | 249 | func parseFavorites(spanDarkText *goquery.Selection) int { 250 | favorites, err := strconv.Atoi(strings.Replace(strings.TrimSpace( 251 | spanDarkText.FilterFunction(isTextEqualFilterFunc("Favorites:")). 252 | Nodes[0]. 253 | NextSibling. 254 | Data), 255 | ",", 256 | "", 257 | -1)) 258 | if err != nil { 259 | log.Printf("error parsing Favorites: %v", err) 260 | return 0 261 | } 262 | 263 | return favorites 264 | } 265 | 266 | func parseStaff(reader *goquery.Document) []Staff { 267 | staff := make([]Staff, 0) 268 | 269 | reader.Selection. 270 | Find("div .detail-characters-list"). 271 | Eq(1). 272 | Find("table"). 273 | Each(func(i int, s *goquery.Selection) { 274 | name := strings.TrimSpace(s.Find("a").Last().Text()) 275 | position := strings.TrimSpace(s.Find("small").Text()) 276 | staff = append(staff, Staff{name, position}) 277 | }) 278 | 279 | return staff 280 | } 281 | 282 | func parseOpeningThemes(reader *goquery.Document) []string { 283 | openingThemes := make([]string, 0) 284 | 285 | reader.Selection. 286 | Find(".opnening span"). 287 | Each(func(i int, s *goquery.Selection) { 288 | song := strings.TrimSpace(s.Text()) 289 | openingThemes = append(openingThemes, song) 290 | }) 291 | 292 | return openingThemes 293 | } 294 | 295 | func parseEndingThemes(reader *goquery.Document) []string { 296 | endingThemes := make([]string, 0) 297 | 298 | reader.Selection. 299 | Find(".ending span"). 300 | Each(func(i int, s *goquery.Selection) { 301 | song := strings.TrimSpace(s.Text()) 302 | endingThemes = append(endingThemes, song) 303 | }) 304 | 305 | return endingThemes 306 | } 307 | -------------------------------------------------------------------------------- /mal/mal.go: -------------------------------------------------------------------------------- 1 | package mal 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/xml" 7 | "fmt" 8 | "github.com/PuerkitoBio/goquery" 9 | "io" 10 | "io/ioutil" 11 | "log" 12 | "net/http" 13 | "net/url" 14 | "strings" 15 | "sync" 16 | "text/template" 17 | ) 18 | 19 | const ( 20 | BaseMALAddress = "https://myanimelist.net" 21 | ApiEndpoint = BaseMALAddress + "/api" 22 | VerifyCredentialsEndpoint = ApiEndpoint + "/account/verify_credentials.xml" 23 | ) 24 | 25 | //For using as a printf format 26 | const ( 27 | UpdateEndpoint = ApiEndpoint + "/animelist/update/%d.xml" //%d - anime database ID 28 | DeleteEndpoint = ApiEndpoint + "/animelist/delete/%d.xml" //%d - anime database ID 29 | 30 | UserAnimeListEndpoint = BaseMALAddress + "/malappinfo.php?u=%s&status=%s&type=anime" //%s - username %s - status 31 | 32 | AnimePage = BaseMALAddress + "/anime/%d" //%d - anime database ID 33 | ) 34 | 35 | type Client struct { 36 | Username string 37 | credentials string 38 | 39 | ID string `xml:"user_id"` 40 | Watching int `xml:"user_watching"` 41 | Completed int `xml:"user_completed"` 42 | OnHold int `xml:"user_onhold"` 43 | Dropped int `xml:"user_dropped"` 44 | PlanToWatch int `xml:"user_plantowatch"` 45 | 46 | DaysSpentWatching float64 `xml:"user_days_spent_watching"` 47 | } 48 | 49 | //credentials should be username + password encoded in the basic auth standard 50 | func NewClient(credentials string) *Client { 51 | c := &Client{} 52 | 53 | credentialsBytes, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(credentials, "Basic ")) 54 | if err != nil { 55 | log.Printf("Decoding credentials failed: %v", err) 56 | } 57 | decodedCredentials := strings.Split(string(credentialsBytes), ":") 58 | c.Username = decodedCredentials[0] 59 | c.credentials = credentials 60 | 61 | return c 62 | } 63 | 64 | func VerifyCredentials(credentials string) bool { 65 | req := newRequest(VerifyCredentialsEndpoint, credentials, http.MethodGet) 66 | 67 | resp, err := http.DefaultClient.Do(req) 68 | if err != nil { 69 | log.Printf("Response error: %v", err) 70 | return false 71 | } 72 | 73 | if resp.StatusCode != 200 { 74 | log.Printf("Credentials verification status: %v", resp.Status) 75 | } 76 | 77 | return resp.StatusCode == 200 78 | } 79 | 80 | func newRequest(url, credentials, method string) *http.Request { 81 | req, err := http.NewRequest(method, url, nil) 82 | if err != nil { 83 | log.Printf("Request creation error: %v", err) 84 | return nil 85 | } 86 | req.Header.Add("Authorization", credentials) 87 | return req 88 | } 89 | 90 | //Note: Since anime list endpoint, besides anime list, returns account stats, this method also 91 | //updates Client with them 92 | func (c *Client) AnimeList(status MyStatus) ([]*Anime, error) { 93 | //NOTE: Anything other than `all` doesn't really work 94 | userListUrl := fmt.Sprintf(UserAnimeListEndpoint, c.Username, "all") 95 | 96 | req, err := http.NewRequest(http.MethodGet, userListUrl, nil) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | resp, err := http.DefaultClient.Do(req) 102 | if err != nil { 103 | return nil, err 104 | } else if resp.StatusCode != 200 { 105 | return nil, fmt.Errorf("server returned: %v", resp.Status) 106 | } 107 | 108 | decoder := xml.NewDecoder(resp.Body) 109 | decoder.Strict = false 110 | 111 | list := make([]*Anime, 0) 112 | 113 | for t, err := decoder.Token(); err != io.EOF; t, err = decoder.Token() { 114 | if t, ok := t.(xml.StartElement); ok { 115 | switch t.Name.Local { 116 | case "myinfo": 117 | decoder.DecodeElement(&c, &t) 118 | case "anime": 119 | anime := new(Anime) 120 | decoder.DecodeElement(&anime, &t) 121 | if anime.MyStatus == status || status == All { 122 | list = append(list, anime) 123 | } 124 | } 125 | } 126 | } 127 | 128 | return list, nil 129 | } 130 | 131 | func (c *Client) Update(entry *Anime) error { 132 | resp, err := c.doApiRequestWithEntryData( 133 | fmt.Sprintf(UpdateEndpoint, entry.ID), 134 | http.MethodPost, 135 | entry, 136 | ) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | bodyBytes, err := ioutil.ReadAll(resp.Body) 142 | if err != nil { 143 | log.Printf("Error reading body: %v", err) 144 | } 145 | body := string(bodyBytes) 146 | 147 | if body != "Updated" || resp.StatusCode != 200 { 148 | return fmt.Errorf("updating failed; server returned: %s", resp.Status) 149 | } 150 | return nil 151 | } 152 | 153 | func (c *Client) Delete(entry *Anime) error { 154 | resp, err := c.doApiRequestWithEntryData( 155 | fmt.Sprintf(DeleteEndpoint, entry.ID), 156 | http.MethodPost, 157 | entry, 158 | ) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | bodyBytes, err := ioutil.ReadAll(resp.Body) 164 | if err != nil { 165 | return fmt.Errorf("reading response body failed: %v", err) 166 | } 167 | body := string(bodyBytes) 168 | 169 | if body != "Deleted" || resp.StatusCode != 200 { 170 | return fmt.Errorf("deleting failed; server returned: %s", resp.Status) 171 | } 172 | return nil 173 | } 174 | 175 | func (c *Client) doApiRequestWithEntryData(address, method string, entry *Anime) (*http.Response, error) { 176 | buf := &bytes.Buffer{} 177 | 178 | template.Must( 179 | template.New("animeXML"). 180 | Parse(AnimeXMLTemplate)). 181 | Execute(buf, entry) 182 | 183 | payload := url.Values{} 184 | payload.Set("data", buf.String()) 185 | 186 | req, err := http.NewRequest( 187 | method, 188 | address, 189 | strings.NewReader(payload.Encode()), 190 | ) 191 | if err != nil { 192 | return nil, fmt.Errorf("error creating http request: %v", err) 193 | } 194 | 195 | req.Header.Set("Authorization", c.credentials) 196 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 197 | 198 | resp, err := http.DefaultClient.Do(req) 199 | if err != nil { 200 | return nil, fmt.Errorf("error getting response for %d - %s update: %v", 201 | entry.ID, entry.Title, err) 202 | } 203 | return resp, nil 204 | } 205 | 206 | //This works by scraping the normal MAL website for given entry. It means that this method 207 | //will stop working when they change something 208 | func (c *Client) FetchDetails(entry *Anime) (*AnimeDetails, error) { 209 | malPageUrl := fmt.Sprintf(AnimePage, entry.ID) 210 | 211 | resp, err := http.DefaultClient.Get(malPageUrl) 212 | if err != nil { 213 | return nil, fmt.Errorf("error getting response: %v", err) 214 | } 215 | if resp.StatusCode != 200 { 216 | return nil, fmt.Errorf("fetching details failed; server returned: %s", resp.Status) 217 | } 218 | 219 | reader, err := goquery.NewDocumentFromReader(resp.Body) 220 | if err != nil { 221 | return nil, fmt.Errorf("error parsing response: %v", err) 222 | } 223 | 224 | details := AnimeDetails{} 225 | 226 | //All functions used below are in the animeparser.go file 227 | wg := sync.WaitGroup{} 228 | wg.Add(1) 229 | go func(d *AnimeDetails) { 230 | defer wg.Done() 231 | details.JapaneseTitle = parseJapaneseTitle(reader) 232 | details.Related = parseRelated(reader) 233 | details.Characters = parseCharacters(reader) 234 | details.Staff = parseStaff(reader) 235 | details.OpeningThemes = parseOpeningThemes(reader) 236 | details.EndingThemes = parseEndingThemes(reader) 237 | details.ScoreVoters = parseScoreVoters(reader) 238 | }(&details) 239 | 240 | wg.Add(1) 241 | go func(d *AnimeDetails) { 242 | defer wg.Done() 243 | synopsisNode := reader.Find("span[itemprop=description]") 244 | 245 | details.Synopsis = parseSynopsis(synopsisNode) 246 | details.Background = parseBackground(synopsisNode) //not working correctly 247 | }(&details) 248 | 249 | spanDarkText := reader.Selection.Find("span[class=dark_text]") 250 | 251 | wg.Add(1) 252 | go func(d *AnimeDetails, spanDarkText *goquery.Selection) { 253 | defer wg.Done() 254 | details.Premiered = parsePremiered(spanDarkText) 255 | details.Broadcast = parseBroadcast(spanDarkText) 256 | details.Producers = parseProducers(spanDarkText) 257 | details.Licensors = parseLicensors(spanDarkText) 258 | details.Studios = parseStudios(spanDarkText) 259 | details.Source = parseSource(spanDarkText) 260 | details.Genres = parseGenres(spanDarkText) 261 | }(&details, spanDarkText) 262 | wg.Add(1) 263 | go func(d *AnimeDetails, spanDarkText *goquery.Selection) { 264 | defer wg.Done() 265 | details.Duration = parseDuration(spanDarkText) 266 | details.Rating = parseRating(spanDarkText) 267 | details.Score = parseScore(spanDarkText) 268 | details.Ranked = parseRanked(spanDarkText) 269 | details.Popularity = parsePopularity(spanDarkText) 270 | details.Members = parseMembers(spanDarkText) 271 | details.Favorites = parseFavorites(spanDarkText) 272 | }(&details, spanDarkText) 273 | 274 | wg.Wait() 275 | 276 | return &details, nil 277 | } 278 | -------------------------------------------------------------------------------- /mal/utils.go: -------------------------------------------------------------------------------- 1 | package mal 2 | 3 | import ( 4 | "strings" 5 | "fmt" 6 | "github.com/PuerkitoBio/goquery" 7 | "github.com/aqatl/cliwait" 8 | ) 9 | 10 | func ParseStatus(status string) MyStatus { 11 | switch strings.ToLower(status) { 12 | case "watching": 13 | return Watching 14 | case "completed": 15 | return Completed 16 | case "onhold": 17 | return OnHold 18 | case "dropped": 19 | return Dropped 20 | case "plantowatch": 21 | return PlanToWatch 22 | default: 23 | return All 24 | } 25 | } 26 | 27 | func ParseScore(score int) (AnimeScore, error) { 28 | if score < 0 || score > 10 { 29 | return 0, fmt.Errorf("score can not be outside of the 0-10 rage") 30 | } 31 | 32 | return AnimeScore(score), nil 33 | } 34 | 35 | func isTextEqualFilterFunc(text string) func(i int, s *goquery.Selection) bool { 36 | return func(i int, s *goquery.Selection) bool { 37 | return s.Text() == text 38 | } 39 | } 40 | 41 | func FetchDetailsWithAnimation(c *Client, entry *Anime) (*AnimeDetails, error) { 42 | var details *AnimeDetails 43 | var err error 44 | cliwait.DoFuncWithWaitAnimation("Fetching details", func() { 45 | details, err = c.FetchDetails(entry) 46 | }) 47 | return details, err 48 | 49 | } 50 | 51 | func UpdateEntryWithAnimation(c *Client, entry *Anime) (error) { 52 | var err error 53 | cliwait.DoFuncWithWaitAnimation("Updating entry", func() { 54 | err = c.Update(entry) 55 | }) 56 | return err 57 | } -------------------------------------------------------------------------------- /mal_app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "sort" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/aqatl/cliwait" 12 | "github.com/aqatl/mal/mal" 13 | "github.com/atotto/clipboard" 14 | "github.com/fatih/color" 15 | "github.com/skratchdot/open-golang/open" 16 | "github.com/urfave/cli" 17 | ) 18 | 19 | func MalApp(app *cli.App) *cli.App { 20 | app.Flags = []cli.Flag{ 21 | cli.BoolFlag{ 22 | Name: "creds, prompt-credentials", 23 | Usage: "Prompt for username and password", 24 | }, 25 | cli.BoolFlag{ 26 | Name: "sp, save-password", 27 | Usage: "save your password. Use with caution, your password can be decoded", 28 | }, 29 | cli.BoolFlag{ 30 | Name: "r, refresh", 31 | Usage: "refreshes cache file", 32 | }, 33 | cli.BoolFlag{ 34 | Name: "ver, verify", 35 | Usage: "verify credentials", 36 | }, 37 | cli.IntFlag{ 38 | Name: "max", 39 | Usage: "visible entries threshold", 40 | }, 41 | cli.BoolFlag{ 42 | Name: "all, a", 43 | Usage: "display all entries; same as --max -1", 44 | }, 45 | cli.StringFlag{ 46 | Name: "status", 47 | Usage: "display entries only with given status " + 48 | "[watching|completed|onhold|dropped|plantowatch]", 49 | }, 50 | cli.StringFlag{ 51 | Name: "sort", 52 | Usage: "display entries sorted by: [last-updated|title|episodes|score]", 53 | }, 54 | cli.BoolFlag{ 55 | Name: "reversed", 56 | Usage: "reversed list order", 57 | }, 58 | } 59 | 60 | app.Commands = []cli.Command{ 61 | cli.Command{ 62 | Name: "switch", 63 | Aliases: []string{"s"}, 64 | Usage: "Switches app mode to AniList", 65 | UsageText: "mal anilist", 66 | Action: switchToAniList, 67 | }, 68 | cli.Command{ 69 | Name: "eps", 70 | Aliases: []string{"episodes"}, 71 | Category: "Update", 72 | Usage: "Set the watched episodes value. " + 73 | "If n not specified, the number will be increased by one", 74 | UsageText: "mal eps ", 75 | Action: setEntryEpisodes, 76 | }, 77 | cli.Command{ 78 | Name: "score", 79 | Category: "Update", 80 | Usage: "Set your rating for selected entry", 81 | UsageText: "mal score <0-10>", 82 | Action: setEntryScore, 83 | }, 84 | cli.Command{ 85 | Name: "status", 86 | Category: "Update", 87 | Usage: "Set your status for selected entry", 88 | UsageText: "mal status [watching|completed|onhold|dropped|plantowatch]", 89 | Action: setEntryStatus, 90 | }, 91 | cli.Command{ 92 | Name: "cmpl", 93 | Category: "Update", 94 | Usage: "Set entry status to completed", 95 | UsageText: "mal cmpl", 96 | Action: setEntryStatusCompleted, 97 | }, 98 | cli.Command{ 99 | Name: "delete", 100 | Aliases: []string{"del"}, 101 | Category: "Update", 102 | Usage: "Delete entry from your list", 103 | UsageText: "mal delete", 104 | Action: deleteEntry, 105 | }, 106 | cli.Command{ 107 | Name: "sel", 108 | Aliases: []string{"select"}, 109 | Category: "Config", 110 | Usage: "Select an entry", 111 | UsageText: "mal sel [entry title]", 112 | Action: selectEntry, 113 | Flags: []cli.Flag{ 114 | cli.BoolFlag{ 115 | Name: "id", 116 | Usage: "Select entry by id instead of by title", 117 | }, 118 | }, 119 | }, 120 | cli.Command{ 121 | Name: "fuzzy-select", 122 | Aliases: []string{"fsel"}, 123 | Category: "Config", 124 | Usage: "Interactive fuzzy search through your list", 125 | UsageText: "mal fsel [search string (optional)]", 126 | Action: fuzzySelectEntry, 127 | }, 128 | cli.Command{ 129 | Name: "cfg", 130 | Aliases: []string{"config", "configuration"}, 131 | Category: "Config", 132 | Usage: "Change config values", 133 | Subcommands: cli.Commands{ 134 | cli.Command{ 135 | Name: "max", 136 | Aliases: []string{"visible"}, 137 | Usage: "Change amount of displayed entries", 138 | UsageText: "mal cfg max [number]", 139 | Action: configChangeMax, 140 | }, 141 | cli.Command{ 142 | Name: "status", 143 | Usage: "Status value of displayed entries", 144 | UsageText: "mal cfg status [all|watching|completed|onhold|dropped|plantowatch]", 145 | Action: configChangeMalStatus, 146 | }, 147 | cli.Command{ 148 | Name: "status-auto-update", 149 | Usage: "Allows entry to be automatically set to completed when number of all episodes is reached or exceeded", 150 | UsageText: "mal cfg status-auto-update [off|normal|after-threshold]", 151 | Action: configChangeAutoUpdateMode, 152 | }, 153 | cli.Command{ 154 | Name: "sort", 155 | Usage: "Specifies sorting mode for the displayed table", 156 | UsageText: "mal cfg sort [last-updated|title|progress|score]", 157 | Action: configChangeSorting, 158 | }, 159 | cli.Command{ 160 | Name: "browser", 161 | Usage: "Specifies a browser to use", 162 | UsageText: "mal cfg browser [browser_path]", 163 | Action: configChangeBrowser, 164 | Flags: []cli.Flag{ 165 | cli.BoolFlag{ 166 | Name: "clear", 167 | Usage: "Clear browser path (return to default)", 168 | }, 169 | }, 170 | }, 171 | cli.Command{ 172 | Name: "torrent", 173 | Usage: "Sets path to torrent client and it args", 174 | UsageText: "mal cfg torrent [path] [args...]", 175 | SkipFlagParsing: true, 176 | Action: configChangeTorrent, 177 | }, 178 | cli.Command{ 179 | Name: "nyaa-quality", 180 | Usage: "Sets default quality filter for nyaa search", 181 | UsageText: "mal cfg nyaa-quality [quality_text]", 182 | SkipFlagParsing: true, 183 | Action: configChangeNyaaQuality, 184 | }, 185 | }, 186 | }, 187 | cli.Command{ 188 | Name: "web", 189 | Aliases: []string{"website", "open", "url"}, 190 | Category: "Action", 191 | Usage: "Open url associated with selected entry or change url if provided", 192 | UsageText: "mal web ", 193 | Action: openWebsite, 194 | Flags: []cli.Flag{ 195 | cli.BoolFlag{ 196 | Name: "clear", 197 | Usage: "Clear url for current entry", 198 | }, 199 | }, 200 | Subcommands: []cli.Command{ 201 | cli.Command{ 202 | Name: "get-all", 203 | Usage: "Print all set urls", 204 | UsageText: "mal web get-all", 205 | Action: printWebsites, 206 | }, 207 | }, 208 | }, 209 | cli.Command{ 210 | Name: "stats", 211 | Category: "Action", 212 | Usage: "Show your account statistics", 213 | UsageText: "mal stats", 214 | Action: malStats, 215 | }, 216 | cli.Command{ 217 | Name: "mal", 218 | Category: "Action", 219 | Usage: "Open MyAnimeList page of selected entry", 220 | UsageText: "mal mal", 221 | Action: malOpenMalSite, 222 | }, 223 | cli.Command{ 224 | Name: "details", 225 | Category: "Action", 226 | Usage: "Print details about selected entry", 227 | UsageText: "mal details", 228 | Action: printDetails, 229 | }, 230 | cli.Command{ 231 | Name: "related", 232 | Category: "Action", 233 | Usage: "Fetch entries related to the selected one", 234 | UsageText: "mal related", 235 | Action: printRelated, 236 | }, 237 | cli.Command{ 238 | Name: "music", 239 | Category: "Action", 240 | Usage: "Print opening and ending themes", 241 | UsageText: "mal music", 242 | Action: printMusic, 243 | }, 244 | cli.Command{ 245 | Name: "broadcast", 246 | Category: "Action", 247 | Usage: "Print broadcast (airing) time", 248 | UsageText: "mal broadcast", 249 | Action: printBroadcast, 250 | }, 251 | cli.Command{ 252 | Name: "copy", 253 | Category: "Action", 254 | Usage: "Copy selected value into system clipboard", 255 | UsageText: "mal copy [title|url]", 256 | Action: copyIntoClipboard, 257 | }, 258 | cli.Command{ 259 | Name: "nyaa", 260 | Aliases: []string{"n"}, 261 | Category: "Action", 262 | Usage: "Open interactive torrent search", 263 | Action: malNyaaCui, 264 | }, 265 | cli.Command{ 266 | Name: "nyaa-web", 267 | Aliases: []string{"nw"}, 268 | Category: "Action", 269 | Usage: "Open torrent search in browser", 270 | UsageText: "mal nyaa-web", 271 | Action: nyaaWebsite, 272 | Flags: []cli.Flag{ 273 | cli.BoolFlag{ 274 | Name: "alt", 275 | Usage: "choose an alternative title", 276 | }, 277 | }, 278 | }, 279 | } 280 | 281 | app.Action = cli.ActionFunc(malDefaultAction) 282 | 283 | return app 284 | } 285 | 286 | func loadMAL(ctx *cli.Context) (*mal.Client, mal.AnimeList, error) { 287 | creds := loadCredentials(ctx) 288 | if ctx.GlobalBool("verify") && !mal.VerifyCredentials(creds) { 289 | return nil, nil, fmt.Errorf("invalid credentials") 290 | } 291 | if ctx.GlobalBool("save-password") { 292 | saveCredentials(creds) 293 | } 294 | 295 | c := mal.NewClient(creds) 296 | if c == nil { 297 | return nil, nil, fmt.Errorf("error creating mal.Client") 298 | } 299 | 300 | list, err := loadData(c, ctx) 301 | 302 | return c, list, err 303 | } 304 | 305 | func malDefaultAction(ctx *cli.Context) error { 306 | _, list, err := loadMAL(ctx) 307 | if err != nil { 308 | return err 309 | } 310 | 311 | cfg := LoadConfig() 312 | 313 | list = list.FilterByStatus(statusFlag(ctx)) 314 | 315 | sorting := cfg.Sorting 316 | if ctx.String("sort") != "" { 317 | if sorting, err = ParseSorting(ctx.String("sort")); err != nil { 318 | return fmt.Errorf("error parsing 'sort' option: %v", err) 319 | } 320 | } 321 | 322 | switch sorting { 323 | case ByLastUpdated: 324 | sort.Sort(mal.AnimeSortByLastUpdated(list)) 325 | case ByTitle: 326 | sort.Sort(mal.AnimeSortByTitle(list)) 327 | case ByWatchedEpisodes: 328 | sort.Sort(sort.Reverse(mal.AnimeSortByWatchedEpisodes(list))) 329 | case ByScore: 330 | sort.Sort(sort.Reverse(mal.AnimeSortByScore(list))) 331 | default: 332 | sort.Sort(mal.AnimeSortByLastUpdated(list)) 333 | } 334 | 335 | if ctx.Bool("reversed") { 336 | reverseAnimeSlice(list) 337 | } 338 | var visibleEntries int 339 | if visibleEntries = ctx.Int("max"); visibleEntries == 0 { 340 | //`Max` flag not specified, get value from config 341 | visibleEntries = cfg.MaxVisibleEntries 342 | } 343 | if visibleEntries > len(list) || visibleEntries < 0 || ctx.Bool("all") { 344 | visibleEntries = len(list) 345 | } 346 | visibleList := list[:visibleEntries] 347 | reverseAnimeSlice(visibleList) 348 | 349 | PrettyList.Execute(color.Output, PrettyListData{visibleList, cfg.SelectedID}) 350 | 351 | if cfg.LastUpdate != *new(time.Time) { 352 | fmt.Printf("\nList last updated: %v (%d days ago)\n", 353 | cfg.LastUpdate, 354 | int(time.Since(cfg.LastUpdate).Hours()/24), 355 | ) 356 | } 357 | 358 | return nil 359 | } 360 | 361 | func statusFlag(ctx *cli.Context) mal.MyStatus { 362 | var status mal.MyStatus 363 | if customStatus := ctx.GlobalString("status"); customStatus != "" { 364 | status = mal.ParseStatus(customStatus) 365 | } else { 366 | cfg := LoadConfig() 367 | status = cfg.Status 368 | } 369 | return status 370 | } 371 | 372 | func statusAutoUpdate(cfg *Config, entry *mal.Anime) { 373 | if cfg.StatusAutoUpdateMode == Off || entry.Episodes == 0 { 374 | return 375 | } 376 | 377 | if (cfg.StatusAutoUpdateMode == Normal && entry.WatchedEpisodes >= entry.Episodes) || 378 | (cfg.StatusAutoUpdateMode == AfterThreshold && entry.WatchedEpisodes > entry.Episodes) { 379 | entry.MyStatus = mal.Completed 380 | entry.WatchedEpisodes = entry.Episodes 381 | return 382 | } 383 | 384 | if entry.MyStatus == mal.Completed && entry.WatchedEpisodes < entry.Episodes { 385 | entry.MyStatus = mal.Watching 386 | return 387 | } 388 | } 389 | 390 | func switchToAniList(ctx *cli.Context) error { 391 | appCfg := AppConfig{} 392 | LoadJsonFile(AppConfigFile, &appCfg) 393 | appCfg.Mode = AniListMode 394 | if err := SaveJsonFile(AppConfigFile, &appCfg); err != nil { 395 | return nil 396 | } 397 | fmt.Println("App mode switched to AniList") 398 | return nil 399 | } 400 | 401 | func setEntryEpisodes(ctx *cli.Context) error { 402 | c, list, err := loadMAL(ctx) 403 | if err != nil { 404 | return err 405 | } 406 | 407 | cfg := LoadConfig() 408 | 409 | if cfg.SelectedID == 0 { 410 | return fmt.Errorf("no entry selected") 411 | } 412 | 413 | selectedEntry := list.GetByID(cfg.SelectedID) 414 | 415 | if selectedEntry == nil { 416 | return fmt.Errorf("no entry found") 417 | } 418 | 419 | epsBefore := selectedEntry.WatchedEpisodes 420 | 421 | if arg := ctx.Args().First(); arg != "" { 422 | n, err := strconv.Atoi(arg) 423 | if err != nil { 424 | return fmt.Errorf("n must be a non-negative integer") 425 | } 426 | if n < 0 { 427 | return fmt.Errorf("n can't be lower than 0") 428 | } 429 | selectedEntry.WatchedEpisodes = n 430 | } else { 431 | selectedEntry.WatchedEpisodes++ 432 | } 433 | 434 | statusAutoUpdate(cfg, selectedEntry) 435 | 436 | if err := mal.UpdateEntryWithAnimation(c, selectedEntry); err != nil { 437 | return err 438 | } 439 | 440 | fmt.Println("Updated successfully") 441 | malPrintEntryDetailsAfterUpdatedEpisodes(selectedEntry, epsBefore) 442 | 443 | cacheList(list) 444 | 445 | return nil 446 | } 447 | 448 | func setEntryScore(ctx *cli.Context) error { 449 | c, list, err := loadMAL(ctx) 450 | if err != nil { 451 | return err 452 | } 453 | cfg := LoadConfig() 454 | 455 | selectedEntry := list.GetByID(cfg.SelectedID) 456 | if selectedEntry == nil { 457 | return fmt.Errorf("no entry selected") 458 | } 459 | 460 | score, err := strconv.Atoi(ctx.Args().First()) 461 | if err != nil { 462 | return fmt.Errorf("error parsing score: %v", err) 463 | } 464 | parsedScore, err := mal.ParseScore(score) 465 | if err != nil { 466 | return err 467 | } 468 | 469 | selectedEntry.MyScore = parsedScore 470 | 471 | if err := mal.UpdateEntryWithAnimation(c, selectedEntry); err != nil { 472 | return err 473 | } 474 | 475 | fmt.Println("Updated successfully") 476 | malPrintEntryDetails(selectedEntry) 477 | 478 | cacheList(list) 479 | 480 | return nil 481 | } 482 | 483 | func setEntryStatus(ctx *cli.Context) error { 484 | c, list, err := loadMAL(ctx) 485 | if err != nil { 486 | return err 487 | } 488 | cfg := LoadConfig() 489 | 490 | selectedEntry := list.GetByID(cfg.SelectedID) 491 | if selectedEntry == nil { 492 | return fmt.Errorf("no entry selected") 493 | } 494 | 495 | status := mal.ParseStatus(ctx.Args().First()) 496 | if status == mal.All { 497 | return fmt.Errorf("invalid status; possible values: watching, completed, " + 498 | "onhold, dropped, plantowatch") 499 | } 500 | 501 | selectedEntry.MyStatus = status 502 | 503 | if err := mal.UpdateEntryWithAnimation(c, selectedEntry); err != nil { 504 | return err 505 | } 506 | 507 | fmt.Println("Updated successfully") 508 | malPrintEntryDetails(selectedEntry) 509 | 510 | cacheList(list) 511 | 512 | return nil 513 | } 514 | 515 | func setEntryStatusCompleted(ctx *cli.Context) error { 516 | c, list, err := loadMAL(ctx) 517 | if err != nil { 518 | return err 519 | } 520 | cfg := LoadConfig() 521 | 522 | selectedEntry := list.GetByID(cfg.SelectedID) 523 | if selectedEntry == nil { 524 | return fmt.Errorf("no entry selected") 525 | } 526 | 527 | selectedEntry.MyStatus = mal.Completed 528 | selectedEntry.WatchedEpisodes = selectedEntry.Episodes 529 | 530 | if err := mal.UpdateEntryWithAnimation(c, selectedEntry); err != nil { 531 | return err 532 | } 533 | 534 | fmt.Println("Updated successfully") 535 | malPrintEntryDetails(selectedEntry) 536 | 537 | cacheList(list) 538 | 539 | return nil 540 | } 541 | 542 | func deleteEntry(ctx *cli.Context) error { 543 | c, list, err := loadMAL(ctx) 544 | if err != nil { 545 | return err 546 | } 547 | cfg := LoadConfig() 548 | 549 | entry := list.GetByID(cfg.SelectedID) 550 | if entry == nil { 551 | return fmt.Errorf("no entry selected") 552 | } 553 | 554 | cliwait.DoFuncWithWaitAnimation("Deleting entry", func() { 555 | err = c.Delete(entry) 556 | }) 557 | if err != nil { 558 | return fmt.Errorf("deleting entry failed\n%v", err) 559 | } 560 | 561 | title := color.HiRedString("%s", entry.Title) 562 | fmt.Fprintf(color.Output, "%s seleted successfully\n", title) 563 | list = list.DeleteByID(entry.ID) 564 | cacheList(list) 565 | 566 | return nil 567 | } 568 | 569 | func selectEntry(ctx *cli.Context) error { 570 | switch { 571 | case ctx.Bool("id"): 572 | return selectById(ctx) 573 | default: 574 | return selectByTitle(ctx) 575 | } 576 | } 577 | 578 | func selectById(ctx *cli.Context) error { 579 | id, err := strconv.Atoi(ctx.Args().First()) 580 | if err != nil { 581 | return fmt.Errorf("invalid id (use with -t to select by title)") 582 | } 583 | 584 | _, list, err := loadMAL(ctx) 585 | if err != nil { 586 | return err 587 | } 588 | cfg := LoadConfig() 589 | 590 | entry := list.GetByID(id) 591 | if entry == nil { 592 | return fmt.Errorf("entry %d not found", id) 593 | } 594 | 595 | cfg.SelectedID = id 596 | cfg.Save() 597 | 598 | fmt.Println("Selected entry:") 599 | malPrintEntryDetails(entry) 600 | 601 | return nil 602 | } 603 | 604 | func selectByTitle(ctx *cli.Context) error { 605 | title := strings.ToLower(strings.Join(ctx.Args(), " ")) 606 | if title == "" { 607 | return showSelectedEntry(ctx) 608 | } 609 | 610 | _, list, err := loadMAL(ctx) 611 | if err != nil { 612 | return err 613 | } 614 | 615 | found := make(mal.AnimeList, 0) 616 | for _, entry := range list { 617 | if strings.Contains(strings.ToLower(entry.Title), title) || 618 | strings.Contains(strings.ToLower(entry.Synonyms), title) { 619 | found = append(found, entry) 620 | } 621 | } 622 | 623 | var selectedEntry *mal.Anime 624 | 625 | if len(found) > 1 { 626 | fmt.Printf("Found more than 1 matching entry:\n") 627 | fmt.Printf("%3s%8s%7s\n", "No.", "ID", "Title") 628 | fmt.Println(strings.Repeat("=", 80)) 629 | 630 | sort.Sort(mal.AnimeSortByLastUpdated(found)) 631 | for i, entry := range found { 632 | fmt.Printf("%3d. %6d: %s\n", i+1, entry.ID, entry.Title) 633 | } 634 | 635 | fmt.Printf("Enter index of the selected entry: ") 636 | idx := 0 637 | _, err := fmt.Scanln(&idx) 638 | idx-- //List is displayed from 1 639 | if err != nil || idx < 0 || idx > len(found)-1 { 640 | return fmt.Errorf("invalid input %v", err) 641 | } 642 | 643 | selectedEntry = found[idx] 644 | } else if len(found) == 0 { 645 | return fmt.Errorf("no matches") 646 | } else { 647 | selectedEntry = found[0] 648 | } 649 | 650 | cfg := LoadConfig() 651 | cfg.SelectedID = selectedEntry.ID 652 | cfg.Save() 653 | 654 | fmt.Println("Selected entry:") 655 | malPrintEntryDetails(selectedEntry) 656 | 657 | return nil 658 | } 659 | 660 | func showSelectedEntry(ctx *cli.Context) error { 661 | cfg := LoadConfig() 662 | _, list, err := loadMAL(ctx) 663 | if err != nil { 664 | return err 665 | } 666 | 667 | selEntry := list.GetByID(cfg.SelectedID) 668 | if selEntry == nil { 669 | return fmt.Errorf("no entry selected") 670 | } 671 | 672 | fmt.Println("Selected entry:") 673 | malPrintEntryDetails(selEntry) 674 | 675 | return nil 676 | } 677 | 678 | func openWebsite(ctx *cli.Context) error { 679 | _, list, err := loadMAL(ctx) 680 | if err != nil { 681 | return nil 682 | } 683 | 684 | cfg := LoadConfig() 685 | 686 | entry := list.GetByID(cfg.SelectedID) 687 | if entry == nil { 688 | return fmt.Errorf("no entry selected") 689 | } 690 | 691 | if newUrl := ctx.Args().First(); newUrl != "" { 692 | cfg.Websites[cfg.SelectedID] = newUrl 693 | cfg.Save() 694 | 695 | fmt.Print("Entry: ") 696 | color.HiYellow("%v", entry.Title) 697 | fmt.Print("URL: ") 698 | color.HiRed("%v", cfg.Websites[cfg.SelectedID]) 699 | 700 | return nil 701 | } 702 | 703 | if ctx.Bool("clear") { 704 | delete(cfg.Websites, cfg.SelectedID) 705 | cfg.Save() 706 | 707 | fmt.Println("Entry cleared") 708 | return nil 709 | } 710 | 711 | if entryUrl, ok := cfg.Websites[cfg.SelectedID]; ok { 712 | if path := cfg.BrowserPath; path == "" { 713 | open.Start(entryUrl) 714 | } else { 715 | open.StartWith(entryUrl, path) 716 | } 717 | 718 | fmt.Println("Opened website for:") 719 | malPrintEntryDetails(entry) 720 | fmt.Fprintf(color.Output, "URL: %v\n", color.CyanString("%v", entryUrl)) 721 | } else { 722 | fmt.Println("Nothing to open") 723 | } 724 | 725 | return nil 726 | } 727 | 728 | func nyaaWebsite(ctx *cli.Context) error { 729 | cfg := LoadConfig() 730 | _, list, err := loadMAL(ctx) 731 | if err != nil { 732 | return err 733 | } 734 | 735 | entry := list.GetByID(cfg.SelectedID) 736 | if entry == nil { 737 | return fmt.Errorf("no entry selected") 738 | } 739 | 740 | var searchTerm string 741 | if ctx.Bool("alt") { 742 | synonyms := formatSynonyms(entry.Synonyms, func(a ...interface{}) string { 743 | return a[0].(string) 744 | }) 745 | if searchTerm = chooseStrFromSlice(synonyms); searchTerm == "" { 746 | return fmt.Errorf("no alternative titles") 747 | } 748 | } else { 749 | searchTerm = entry.Title 750 | } 751 | 752 | address := "https://nyaa.si/?f=0&c=1_2&q=" + url.QueryEscape(searchTerm) 753 | 754 | if path := cfg.BrowserPath; path == "" { 755 | open.Start(address) 756 | } else { 757 | open.StartWith(address, path) 758 | } 759 | 760 | fmt.Println("Searched for:") 761 | malPrintEntryDetails(entry) 762 | 763 | return nil 764 | } 765 | 766 | func malStats(ctx *cli.Context) error { 767 | c, list, err := loadMAL(ctx) 768 | if err != nil { 769 | return err 770 | } 771 | 772 | yellow := color.New(color.FgHiYellow).SprintFunc() 773 | red := color.New(color.FgHiRed).SprintFunc() 774 | cyan := color.New(color.FgHiCyan).SprintfFunc() 775 | magenta := color.New(color.FgHiMagenta).SprintFunc() 776 | 777 | totalEntries := c.Watching + c.Completed + c.Dropped + c.OnHold + c.PlanToWatch 778 | 779 | watchedEps, rewatchedSeries := 0, 0 780 | for _, entry := range list { 781 | watchedEps += entry.WatchedEpisodes 782 | rewatchedSeries += entry.MyRewatching 783 | } 784 | 785 | hoursSpentWatching := c.DaysSpentWatching * 24.0 786 | 787 | fmt.Fprintf( 788 | color.Output, 789 | "Username: %s\n\n"+ 790 | "Watching: %s\n"+ 791 | "Completed: %s\n"+ 792 | "Dropped: %s\n"+ 793 | "On hold: %s\n"+ 794 | "Plan to watch: %s\n\n"+ 795 | "Total entries: %s\n"+ 796 | "Episodes watched: %s\n"+ 797 | "Times rewatched: %s\n\n"+ 798 | "Days spent watching: %s (%s hours)\n", 799 | yellow(c.Username), 800 | red(c.Watching), 801 | red(c.Completed), 802 | red(c.Dropped), 803 | red(c.OnHold), 804 | red(c.PlanToWatch), 805 | magenta(totalEntries), 806 | magenta(watchedEps), 807 | magenta(rewatchedSeries), 808 | cyan("%.2f", c.DaysSpentWatching), 809 | cyan("%.2f", hoursSpentWatching), 810 | ) 811 | 812 | return nil 813 | } 814 | 815 | func malOpenMalSite(ctx *cli.Context) error { 816 | _, list, err := loadMAL(ctx) 817 | if err != nil { 818 | return err 819 | } 820 | 821 | cfg := LoadConfig() 822 | id := cfg.SelectedID 823 | if id <= 0 { 824 | return fmt.Errorf("no entry selected") 825 | } 826 | 827 | openMalSite(cfg, cfg.SelectedID) 828 | fmt.Println("Opened website for:") 829 | malPrintEntryDetails(list.GetByID(cfg.SelectedID)) 830 | 831 | return nil 832 | } 833 | 834 | func openMalSite(cfg *Config, malId int) { 835 | if path, args := cfg.BrowserPath, fmt.Sprintf(mal.AnimePage, malId); path == "" { 836 | open.Start(args) 837 | } else { 838 | open.StartWith(args, path) 839 | } 840 | } 841 | 842 | func printWebsites(ctx *cli.Context) error { 843 | _, list, err := loadMAL(ctx) 844 | if err != nil { 845 | return err 846 | } 847 | 848 | cfg := LoadConfig() 849 | 850 | for k, v := range cfg.Websites { 851 | entryUrl := fmt.Sprintf("\033[3%d;%dm%s\033[0m ", 3, 1, v) 852 | 853 | var title string 854 | if entry := list.GetByID(k); entry != nil { 855 | title = entry.Title 856 | } 857 | 858 | fmt.Fprintf(color.Output, "%6d (%s): %s\n", k, title, entryUrl) 859 | } 860 | 861 | return nil 862 | } 863 | 864 | func printDetails(ctx *cli.Context) error { 865 | cfg := LoadConfig() 866 | c, list, err := loadMAL(ctx) 867 | if err != nil { 868 | return err 869 | } 870 | 871 | entry := list.GetByID(cfg.SelectedID) 872 | if entry == nil { 873 | return fmt.Errorf("no entry selected") 874 | } 875 | 876 | details, err := mal.FetchDetailsWithAnimation(c, entry) 877 | if err != nil { 878 | return err 879 | } 880 | 881 | printSlice := func(slice []string) { 882 | for _, str := range slice { 883 | fmt.Fprintf(color.Output, "\t%s\n", str) 884 | } 885 | } 886 | 887 | yellow := color.New(color.FgHiYellow).SprintFunc() 888 | red := color.New(color.FgHiRed).SprintFunc() 889 | cyan := color.New(color.FgHiCyan).SprintFunc() 890 | green := color.New(color.FgHiGreen).SprintFunc() 891 | 892 | fmt.Fprintln(color.Output, "Title:", yellow(entry.Title)) 893 | fmt.Fprintln(color.Output, "Japanese title:", yellow(details.JapaneseTitle)) 894 | fmt.Fprintln(color.Output, "Series synonyms:") 895 | printSlice(formatSynonyms(entry.Synonyms, yellow)) 896 | fmt.Fprintln(color.Output, "Series type:", yellow(entry.Type)) 897 | fmt.Fprintln(color.Output, "Series status:", yellow(entry.Status)) 898 | fmt.Fprintln(color.Output, "Series premiered:", yellow(details.Premiered)) 899 | fmt.Fprintln(color.Output, "Series start:", yellow(entry.SeriesStart)) 900 | fmt.Fprintln(color.Output, "Series end:", yellow(entry.SeriesEnd)) 901 | fmt.Fprintln(color.Output, "Series score:", red(details.Score), 902 | "(by", red(details.ScoreVoters), "voters)") 903 | fmt.Fprintln(color.Output, "Series popularity:", "#"+red(details.Popularity)) 904 | fmt.Fprintln(color.Output, "Series rating:", "#"+yellow(details.Rating)) 905 | fmt.Fprintln(color.Output, "Duration:", yellow(details.Duration)) 906 | fmt.Fprintln(color.Output, "Genres:") 907 | printSlice(formatGenres(details.Genres, yellow)) 908 | 909 | fmt.Fprintln(color.Output, "Episodes:", red(entry.WatchedEpisodes), "/", red(entry.Episodes)) 910 | fmt.Fprintln(color.Output, "Score:", red(entry.MyScore)) 911 | fmt.Fprintln(color.Output, "Status:", yellow(entry.MyStatus)) 912 | fmt.Fprintln(color.Output, "Last updated:", red(time.Unix(entry.LastUpdated, 0))) 913 | fmt.Fprintln(color.Output, "Website url:", cyan(cfg.Websites[entry.ID])) 914 | 915 | fmt.Fprintln(color.Output) 916 | 917 | fmt.Fprintln(color.Output, "Synposis:", green(details.Synopsis)) 918 | 919 | return nil 920 | } 921 | 922 | type sPrintFunc func(a ...interface{}) string 923 | 924 | func formatSynonyms(synonyms string, sPrintFunc sPrintFunc) []string { 925 | synoSplit := strings.Split(synonyms, ";") 926 | for i, length := 0, len(synoSplit); i < length; { 927 | if synoSplit[i] == "" { 928 | synoSplit = synoSplit[:i+copy(synoSplit[i:], synoSplit[i+1:])] 929 | length-- 930 | } else { 931 | synoSplit[i] = sPrintFunc(strings.TrimSpace(synoSplit[i])) 932 | i++ 933 | } 934 | } 935 | return synoSplit 936 | } 937 | 938 | func formatGenres(genres []string, sPrintFunc sPrintFunc) []string { 939 | if length := len(genres); length == 0 { 940 | return genres 941 | } else if length == 1 { 942 | genres[0] = strings.Trim(genres[0], "[]") 943 | } else { 944 | genres[0] = strings.TrimLeft(genres[0], "[") 945 | genres[length-1] = strings.TrimRight(genres[length-1], "]") 946 | } 947 | for i := range genres { 948 | genres[i] = sPrintFunc(genres[i]) 949 | } 950 | return genres 951 | } 952 | 953 | func printRelated(ctx *cli.Context) error { 954 | cfg := LoadConfig() 955 | c, list, err := loadMAL(ctx) 956 | if err != nil { 957 | return err 958 | } 959 | 960 | selEntry := list.GetByID(cfg.SelectedID) 961 | if selEntry == nil { 962 | return fmt.Errorf("no entry selected") 963 | } 964 | 965 | details, err := mal.FetchDetailsWithAnimation(c, selEntry) 966 | if err != nil { 967 | return err 968 | } 969 | 970 | for _, related := range details.Related { 971 | title := color.HiYellowString("%s", related.Title) 972 | fmt.Fprintf(color.Output, "%s: %s (%s)\n", related.Relation, title, related.Url) 973 | } 974 | 975 | return nil 976 | } 977 | 978 | func printMusic(ctx *cli.Context) error { 979 | cfg := LoadConfig() 980 | c, list, err := loadMAL(ctx) 981 | if err != nil { 982 | return err 983 | } 984 | 985 | entry := list.GetByID(cfg.SelectedID) 986 | if entry == nil { 987 | return fmt.Errorf("no entry selected") 988 | } 989 | 990 | details, err := mal.FetchDetailsWithAnimation(c, entry) 991 | 992 | printThemes := func(themes []string) { 993 | for _, theme := range themes { 994 | fmt.Fprintf( 995 | color.Output, " %s\n", 996 | color.HiYellowString("%s", strings.TrimSpace(theme))) 997 | } 998 | } 999 | 1000 | fmt.Fprintln(color.Output, "Openings:") 1001 | printThemes(details.OpeningThemes) 1002 | 1003 | fmt.Fprintln(color.Output, "\nEndings:") 1004 | printThemes(details.EndingThemes) 1005 | 1006 | return nil 1007 | } 1008 | 1009 | func printBroadcast(ctx *cli.Context) error { 1010 | cfg := LoadConfig() 1011 | c, list, err := loadMAL(ctx) 1012 | if err != nil { 1013 | return err 1014 | } 1015 | 1016 | entry := list.GetByID(cfg.SelectedID) 1017 | if entry == nil { 1018 | return fmt.Errorf("no entry selected") 1019 | } 1020 | 1021 | yellow := color.New(color.FgHiYellow).SprintFunc() 1022 | green := color.New(color.FgHiGreen).SprintFunc() 1023 | 1024 | if entry.Status != mal.CurrentlyAiring { 1025 | return fmt.Errorf("%s isn't currently airing", yellow(entry.Title)) 1026 | } 1027 | 1028 | details, err := mal.FetchDetailsWithAnimation(c, entry) 1029 | if err != nil { 1030 | return err 1031 | } 1032 | 1033 | fmt.Fprintf(color.Output, "Title: %s\nBroadcast: %s\n", 1034 | yellow(entry.Title), 1035 | green(details.Broadcast)) 1036 | 1037 | return nil 1038 | } 1039 | 1040 | func copyIntoClipboard(ctx *cli.Context) error { 1041 | cfg := LoadConfig() 1042 | _, list, err := loadMAL(ctx) 1043 | if err != nil { 1044 | return err 1045 | } 1046 | 1047 | entry := list.GetByID(cfg.SelectedID) 1048 | if entry == nil { 1049 | return fmt.Errorf("no entry selected") 1050 | } 1051 | 1052 | var text string 1053 | 1054 | switch strings.ToLower(ctx.Args().First()) { 1055 | case "title": 1056 | text = entry.Title 1057 | case "url": 1058 | entryUrl, ok := cfg.Websites[cfg.SelectedID] 1059 | if !ok { 1060 | return fmt.Errorf("no url to copy") 1061 | } 1062 | text = entryUrl 1063 | default: 1064 | return fmt.Errorf("usage: mal copy [title|url]") 1065 | } 1066 | 1067 | if err = clipboard.WriteAll(text); err == nil { 1068 | fmt.Fprintln(color.Output, "Text", color.HiYellowString("%s", text), "copied into clipboard") 1069 | } 1070 | 1071 | return err 1072 | } 1073 | -------------------------------------------------------------------------------- /mal_cache.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/xml" 6 | "fmt" 7 | "github.com/aqatl/cliwait" 8 | "github.com/aqatl/mal/mal" 9 | "github.com/urfave/cli" 10 | "golang.org/x/crypto/ssh/terminal" 11 | "io" 12 | "io/ioutil" 13 | "log" 14 | "os" 15 | "strings" 16 | "syscall" 17 | "time" 18 | ) 19 | 20 | func loadCredentials(ctx *cli.Context) string { 21 | if ctx.GlobalBool("prompt-credentials") { //Read credentials from console 22 | reader := bufio.NewReader(os.Stdin) 23 | 24 | fmt.Print("Enter username: ") 25 | username, _ := reader.ReadString('\n') 26 | 27 | fmt.Print("Enter password (chars hidden): ") 28 | bytePassword, err := terminal.ReadPassword(int(syscall.Stdin)) 29 | fmt.Println() 30 | if err != nil { 31 | log.Printf("Error reading password: %v", err) 32 | } 33 | password := string(bytePassword) 34 | 35 | return basicAuth(strings.TrimSpace(username), strings.TrimSpace(password)) 36 | } else { //Read credentials from CredentialsFile 37 | credentials, err := ioutil.ReadFile(MalCredentialsFile) 38 | if err != nil { 39 | log.Printf("Failed to load credentials: %v", err) 40 | return "" 41 | } 42 | return string(credentials) 43 | } 44 | } 45 | 46 | func saveCredentials(credentials string) { 47 | err := ioutil.WriteFile(MalCredentialsFile, []byte(credentials), 400) 48 | if err != nil { 49 | log.Printf("Caching credentials failed: %v", err) 50 | } 51 | } 52 | 53 | // Loads Client statistic data and returns Client's AnimeList 54 | func loadData(c *mal.Client, ctx *cli.Context) (mal.AnimeList, error) { 55 | var list []*mal.Anime 56 | 57 | if ctx.GlobalBool("refresh") || cacheNotExist() { 58 | { 59 | var err error 60 | cliwait.DoFuncWithWaitAnimation("Fetching your list", func() { 61 | list, err = c.AnimeList(mal.All) 62 | }) 63 | return nil, fmt.Errorf("error fetching your list\n%v", err) 64 | } 65 | 66 | cacheList(list) 67 | cacheClient(c) 68 | 69 | cfg := LoadConfig() 70 | cfg.LastUpdate = time.Now() 71 | cfg.Save() 72 | } else { 73 | list = loadCachedList() 74 | loadCachedStats(c) 75 | } 76 | return list, nil 77 | } 78 | 79 | func cacheNotExist() bool { 80 | f, err := os.Open(MalCacheFile) 81 | defer f.Close() 82 | f2, err2 := os.Open(MalStatsCacheFile) 83 | defer f2.Close() 84 | return os.IsNotExist(err) || os.IsNotExist(err2) 85 | } 86 | 87 | func cacheList(list []*mal.Anime) { 88 | f, err := os.Create(MalCacheFile) 89 | defer f.Close() 90 | if err != nil { 91 | log.Printf("Error creating %s file: %v", MalCacheFile, err) 92 | return 93 | } 94 | 95 | encoder := xml.NewEncoder(f) 96 | if err := encoder.Encode(list); err != nil { 97 | log.Printf("Encoding error: %v", err) 98 | } 99 | } 100 | 101 | func loadCachedList() mal.AnimeList { 102 | f, err := os.Open(MalCacheFile) 103 | defer f.Close() 104 | if err != nil { 105 | log.Printf("Error opening %s file: %v", MalCacheFile, err) 106 | return nil 107 | } 108 | 109 | list := make([]*mal.Anime, 0) 110 | 111 | decoder := xml.NewDecoder(f) 112 | for t, err := decoder.Token(); err != io.EOF; t, err = decoder.Token() { 113 | if t, ok := t.(xml.StartElement); ok && t.Name.Local == "Anime" { 114 | var anime mal.Anime 115 | decoder.DecodeElement(&anime, &t) 116 | list = append(list, &anime) 117 | } 118 | } 119 | 120 | return list 121 | } 122 | 123 | func cacheClient(c *mal.Client) { 124 | f, err := os.Create(MalStatsCacheFile) 125 | defer f.Close() 126 | if err != nil { 127 | log.Printf("Error opening %s file: %v", MalStatsCacheFile, err) 128 | return 129 | } 130 | 131 | encoder := xml.NewEncoder(f) 132 | if err := encoder.Encode(c); err != nil { 133 | log.Printf("Encoding error: %v", err) 134 | } 135 | } 136 | 137 | func loadCachedStats(c *mal.Client) { 138 | f, err := os.Open(MalStatsCacheFile) 139 | defer f.Close() 140 | if err != nil { 141 | log.Printf("Error opening %s file: %v", MalCacheFile, err) 142 | return 143 | } 144 | 145 | decoder := xml.NewDecoder(f) 146 | if err := decoder.Decode(c); err != nil { 147 | log.Printf("Error decoding %s file", MalStatsCacheFile) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /nyaa_cui.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "os/exec" 7 | "strings" 8 | 9 | "regexp" 10 | 11 | "sort" 12 | 13 | "github.com/aqatl/mal/dialog" 14 | ns "github.com/aqatl/mal/nyaa_scraper" 15 | "github.com/atotto/clipboard" 16 | "github.com/fatih/color" 17 | "github.com/jroimartin/gocui" 18 | "github.com/urfave/cli" 19 | ) 20 | 21 | func malNyaaCui(ctx *cli.Context) error { 22 | _, list, err := loadMAL(ctx) 23 | if err != nil { 24 | return err 25 | } 26 | cfg := LoadConfig() 27 | 28 | entry := list.GetByID(cfg.SelectedID) 29 | if entry == nil { 30 | return fmt.Errorf("no entry found") 31 | } 32 | return startNyaaCui( 33 | cfg, 34 | entry.Title, 35 | fmt.Sprintf("%s %d/%d", entry.Title, entry.WatchedEpisodes, entry.Episodes), 36 | cfg.NyaaQuality, 37 | ) 38 | } 39 | 40 | type NyaaAlt struct { 41 | Query string 42 | Id int 43 | } 44 | 45 | func alNyaaCui(ctx *cli.Context) error { 46 | al, err := loadAniList(ctx) 47 | if err != nil { 48 | return err 49 | } 50 | cfg := LoadConfig() 51 | 52 | entry := al.GetMediaListById(cfg.ALSelectedID) 53 | if entry == nil { 54 | return fmt.Errorf("no entry found") 55 | } 56 | 57 | if alt := ctx.String("custom"); alt != "" { 58 | addCustomAlt(alt+" "+strings.Join(ctx.Args(), " "), cfg) 59 | return nil 60 | } 61 | 62 | var customAlt *string = nil 63 | for _, alt := range cfg.NyaaAlts { 64 | if alt.Id == entry.Id { 65 | customAlt = &alt.Query 66 | break 67 | } 68 | } 69 | 70 | searchTerm := entry.Title.UserPreferred 71 | if ctx.Bool("alt") { 72 | fmt.Printf("Select desired title\n\n") 73 | alts := sliceOfEntryTitles(entry) 74 | if customAlt != nil { 75 | alts = append(alts, *customAlt) 76 | } 77 | if searchTerm = chooseStrFromSlice(alts); searchTerm == "" { 78 | return fmt.Errorf("no alternative titles") 79 | } 80 | } else if ctx.NArg() > 0 { 81 | searchTerm = strings.Join(ctx.Args(), " ") 82 | } else if customAlt != nil { 83 | searchTerm = *customAlt 84 | } 85 | 86 | if err := startNyaaCui( 87 | cfg, 88 | searchTerm, 89 | fmt.Sprintf("%s %d/%d", searchTerm, entry.Progress, entry.Episodes), 90 | cfg.NyaaQuality, 91 | ); err != nil { 92 | return err 93 | } 94 | 95 | alPrintEntryDetails(entry, al.User.MediaListOptions.ScoreFormat) 96 | return nil 97 | } 98 | 99 | func addCustomAlt(newAlt string, cfg *Config) { 100 | // Assumes the entry ID is valid 101 | defer cfg.Save() 102 | for i, alt := range cfg.NyaaAlts { 103 | if alt.Id == cfg.ALSelectedID { 104 | cfg.NyaaAlts[i].Query = newAlt 105 | return 106 | } 107 | } 108 | 109 | cfg.NyaaAlts = append(cfg.NyaaAlts, NyaaAlt{ 110 | Id: cfg.ALSelectedID, 111 | Query: newAlt, 112 | }) 113 | } 114 | 115 | func startNyaaCui(cfg *Config, searchTerm, displayedInfo, quality string) error { 116 | gui, err := gocui.NewGui(gocui.Output256) 117 | defer gui.Close() 118 | if err != nil { 119 | return fmt.Errorf("gocui error: %v", err) 120 | } 121 | 122 | qualityRe, err := regexp.Compile(regexp.QuoteMeta(quality)) 123 | if err != nil { 124 | return fmt.Errorf("failed to parse your quality tag") 125 | } 126 | 127 | nc := &nyaaCui{ 128 | Gui: gui, 129 | Cfg: cfg, 130 | 131 | SearchTerm: searchTerm, 132 | DisplayedInfo: displayedInfo, 133 | Category: ns.AnimeEnglishTranslated, 134 | Filter: ns.TrustedOnly, 135 | 136 | QualityFilter: qualityRe, 137 | } 138 | gui.SetManager(nc) 139 | nc.setGuiKeyBindings(gui) 140 | 141 | gui.Cursor = false 142 | gui.Mouse = false 143 | gui.Highlight = true 144 | gui.SelFgColor = gocui.ColorGreen 145 | 146 | gui.Update(func(gui *gocui.Gui) error { 147 | nc.Reload() 148 | return nil 149 | }) 150 | 151 | if err = gui.MainLoop(); err != nil && err != gocui.ErrQuit { 152 | return err 153 | } 154 | return nil 155 | } 156 | 157 | const ( 158 | ncInfoView = "ncInfoView" 159 | ncResultsView = "ncResultsView " 160 | ncShortcutsView = "ncShortcutsView" 161 | ) 162 | 163 | type nyaaCui struct { 164 | Gui *gocui.Gui 165 | Cfg *Config 166 | 167 | SearchTerm string 168 | DisplayedInfo string 169 | Category ns.NyaaCategory 170 | Filter ns.NyaaFilter 171 | 172 | Results []ns.NyaaEntry 173 | MaxResults int 174 | MaxPages int 175 | LoadedPages int 176 | 177 | TitleFilter *regexp.Regexp 178 | QualityFilter *regexp.Regexp 179 | 180 | ResultsView *gocui.View 181 | DisplayedIndexes []int 182 | } 183 | 184 | var red = color.New(color.FgRed).SprintFunc() 185 | var cyan = color.New(color.FgCyan).SprintFunc() 186 | var blue = color.New(color.FgBlue).SprintFunc() 187 | var green = color.New(color.FgGreen).SprintFunc() 188 | 189 | var boldRed = color.New(color.FgRed).Add(color.Bold).SprintFunc() 190 | var boldGreen = color.New(color.FgGreen).Add(color.Bold).SprintFunc() 191 | var boldYellow = color.New(color.FgYellow).Add(color.Bold).SprintFunc() 192 | 193 | func (nc *nyaaCui) Layout(gui *gocui.Gui) error { 194 | w, h := gui.Size() 195 | 196 | if v, err := gui.SetView(ncResultsView, 0, 3, w-1, h-4); err != nil { 197 | if err != gocui.ErrUnknownView { 198 | return err 199 | } 200 | 201 | v.Title = "Search results" 202 | v.SelBgColor = gocui.ColorGreen 203 | v.SelFgColor = gocui.ColorBlack 204 | v.Highlight = true 205 | v.Editable = true 206 | v.Editor = gocui.EditorFunc(nc.GetEditor()) 207 | 208 | gui.SetCurrentView(ncResultsView) 209 | nc.ResultsView = v 210 | 211 | // TODO Better/clearer results printing 212 | nc.DisplayedIndexes = make([]int, 0, len(nc.Results)) 213 | for i, result := range nc.Results { 214 | if nc.TitleFilter != nil && !nc.TitleFilter.MatchString(result.Title) { 215 | continue 216 | } 217 | if nc.QualityFilter != nil && !nc.QualityFilter.MatchString(result.Title) { 218 | continue 219 | } 220 | 221 | title := result.Title 222 | switch result.Class { 223 | case ns.Default: 224 | title = boldYellow(title) 225 | case ns.Trusted: 226 | title = boldGreen(title) 227 | case ns.Danger: 228 | title = boldRed(title) 229 | } 230 | 231 | fmt.Fprintln(v, 232 | title, 233 | red(result.Size), 234 | cyan(result.DateAdded.Format("15:04 02-01-2006")), 235 | green(result.Seeders), 236 | red(result.Leechers), 237 | blue(result.CompletedDownloads), 238 | ) 239 | nc.DisplayedIndexes = append(nc.DisplayedIndexes, i) 240 | } 241 | } 242 | 243 | if v, err := gui.SetView(ncInfoView, 0, 0, w-1, 2); err != nil { 244 | if err != gocui.ErrUnknownView { 245 | return err 246 | } 247 | 248 | v.Title = "Info" 249 | v.Editable = false 250 | 251 | fmt.Fprintf(v, "[%s]: displaying %d out of %d results", 252 | nc.DisplayedInfo, len(nc.DisplayedIndexes), nc.MaxResults) 253 | } 254 | 255 | if v, err := gui.SetView(ncShortcutsView, 0, h-3, w-1, h-1); err != nil { 256 | if err != gocui.ErrUnknownView { 257 | return err 258 | } 259 | 260 | v.Title = "Shortcuts" 261 | v.Editable = false 262 | 263 | c := color.New(color.FgCyan).SprintFunc() 264 | fmt.Fprintln(v, 265 | c("d"), "download", 266 | c("D"), "copy torrent link", 267 | c("l"), "load next page", 268 | c("c"), "category", 269 | c("f"), "filters", 270 | c("t"), "tags", 271 | c("p"), "quality", 272 | c("r"), "reload", 273 | ) 274 | } 275 | 276 | return nil 277 | } 278 | 279 | func (nc *nyaaCui) GetEditor() func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 280 | return func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 281 | switch { 282 | case key == gocui.KeyArrowDown || ch == 'j': 283 | _, oy := v.Origin() 284 | _, y := v.Cursor() 285 | y += oy 286 | if y < len(nc.DisplayedIndexes)-1 { 287 | v.MoveCursor(0, 1, false) 288 | } 289 | case key == gocui.KeyArrowUp || ch == 'k': 290 | v.MoveCursor(0, -1, false) 291 | case ch == 'g': 292 | v.SetCursor(0, 0) 293 | v.SetOrigin(0, 0) 294 | case ch == 'G': 295 | _, viewH := v.Size() 296 | totalH := len(nc.DisplayedIndexes) 297 | if totalH <= viewH { 298 | v.SetCursor(0, totalH-1) 299 | } else { 300 | v.SetOrigin(0, totalH-viewH) 301 | v.SetCursor(0, viewH-1) 302 | } 303 | case ch == 'd': 304 | _, y := v.Cursor() 305 | _, oy := v.Origin() 306 | y += oy 307 | nc.Download(y) 308 | case ch == 'l': 309 | nc.LoadNextPage() 310 | case ch == 'c': 311 | nc.ChangeCategory() 312 | case ch == 'f': 313 | nc.ChangeFilter() 314 | case ch == 't': 315 | nc.FilterByTag() 316 | case ch == 'p': 317 | nc.FilterByQuality() 318 | case ch == 'r': 319 | nc.Reload() 320 | case ch == 'D': 321 | _, y := v.Cursor() 322 | _, oy := v.Origin() 323 | y += oy 324 | nc.CopyLinkToClipboard(y) 325 | } 326 | } 327 | } 328 | 329 | func (nc *nyaaCui) Reload() { 330 | var resultPage ns.NyaaResultPage 331 | var searchErr error 332 | f := func() { 333 | resultPage, searchErr = ns.Search(nc.SearchTerm, nc.Category, nc.Filter) 334 | } 335 | jobDone, err := dialog.StuffLoader(dialog.FitMessage(nc.Gui, "Loading "+nc.SearchTerm), f) 336 | if err != nil { 337 | gocuiReturnError(nc.Gui, err) 338 | } 339 | go func() { 340 | ok := <-jobDone 341 | if searchErr != nil { 342 | dialog.JustShowOkDialog(nc.Gui, "Error", searchErr.Error()) 343 | return 344 | } 345 | if ok { 346 | nc.Results = resultPage.Results 347 | nc.MaxResults = resultPage.DisplayedOutOf 348 | nc.MaxPages = int(math.Ceil(float64(resultPage.DisplayedOutOf) / 349 | float64(resultPage.DisplayedTo-resultPage.DisplayedFrom+1))) 350 | nc.LoadedPages = 1 351 | } 352 | 353 | nc.Gui.Update(func(gui *gocui.Gui) error { 354 | gui.DeleteView(ncResultsView) 355 | gui.DeleteView(ncInfoView) 356 | return nil 357 | }) 358 | }() 359 | } 360 | 361 | func (nc *nyaaCui) Download(yIdx int) { 362 | if yIdx >= len(nc.DisplayedIndexes) { 363 | return 364 | } 365 | 366 | link := "" 367 | if entry := nc.Results[nc.DisplayedIndexes[yIdx]]; entry.MagnetLink != "" { 368 | link = entry.MagnetLink 369 | } else if entry.TorrentLink != "" { 370 | link = entry.TorrentLink 371 | } else { 372 | dialog.JustShowOkDialog(nc.Gui, "Error", "No link found") 373 | return 374 | } 375 | 376 | args := make([]string, len(nc.Cfg.TorrentClientArgs)) 377 | copy(args, nc.Cfg.TorrentClientArgs) 378 | args = append(args, link) 379 | 380 | cmd := exec.Command(nc.Cfg.TorrentClientPath, args...) 381 | if err := cmd.Start(); err != nil { 382 | gocuiReturnError(nc.Gui, err) 383 | } 384 | } 385 | 386 | func (nc *nyaaCui) CopyLinkToClipboard(yIdx int) { 387 | if yIdx >= len(nc.DisplayedIndexes) { 388 | return 389 | } 390 | 391 | link := "" 392 | if entry := nc.Results[nc.DisplayedIndexes[yIdx]]; entry.MagnetLink != "" { 393 | link = entry.MagnetLink 394 | } else if entry.TorrentLink != "" { 395 | link = entry.TorrentLink 396 | } else { 397 | dialog.JustShowOkDialog(nc.Gui, "Error", "No link found") 398 | return 399 | } 400 | 401 | if err := clipboard.WriteAll(link); err == nil { 402 | dialog.JustShowOkDialog(nc.Gui, "Clipboard", "Link copied into clipboard") 403 | } 404 | } 405 | 406 | func (nc *nyaaCui) LoadNextPage() { 407 | if nc.LoadedPages >= nc.MaxPages { 408 | return 409 | } 410 | nc.LoadedPages++ 411 | go func() { 412 | resultPage, _ := ns.SearchSpecificPage( 413 | nc.SearchTerm, 414 | nc.Category, 415 | nc.Filter, 416 | nc.LoadedPages, 417 | ) 418 | nc.Results = append(nc.Results, resultPage.Results...) 419 | nc.Gui.Update(func(gui *gocui.Gui) error { 420 | _, oy := nc.ResultsView.Origin() 421 | _, y := nc.ResultsView.Cursor() 422 | 423 | gui.DeleteView(ncInfoView) 424 | gui.DeleteView(ncResultsView) 425 | 426 | nc.Layout(gui) 427 | nc.ResultsView.SetOrigin(0, oy) 428 | nc.ResultsView.SetCursor(0, y) 429 | 430 | return nil 431 | }) 432 | }() 433 | } 434 | 435 | func (nc *nyaaCui) ChangeCategory() { 436 | selIdxChan, cleanUp, err := dialog.ListSelect(nc.Gui, "Select category", ns.Categories, false) 437 | if err != nil { 438 | gocuiReturnError(nc.Gui, err) 439 | } 440 | go func() { 441 | idxs, ok := <-selIdxChan 442 | nc.Gui.Update(cleanUp) 443 | if ok { 444 | nc.Category = ns.Categories[idxs[0]] 445 | nc.Reload() 446 | } 447 | }() 448 | } 449 | 450 | func (nc *nyaaCui) ChangeFilter() { 451 | selIdxChan, cleanUp, err := dialog.ListSelect(nc.Gui, "Select filter", ns.Filters, false) 452 | if err != nil { 453 | gocuiReturnError(nc.Gui, err) 454 | } 455 | go func() { 456 | idxs, ok := <-selIdxChan 457 | nc.Gui.Update(cleanUp) 458 | if ok { 459 | nc.Filter = ns.Filters[idxs[0]] 460 | nc.Reload() 461 | } 462 | }() 463 | } 464 | 465 | var tagRegex = `(?U)\[(.+)\]` 466 | 467 | func (nc *nyaaCui) FilterByTag() { 468 | tags := make([]string, 1, len(nc.Results)+1) 469 | tagsDup := make(map[string]struct{}) 470 | re := regexp.MustCompile(tagRegex) 471 | for _, result := range nc.Results { 472 | if tsm := re.FindStringSubmatch(result.Title); len(tsm) >= 2 && tsm[1] != "" { 473 | if _, ok := tagsDup[tsm[1]]; !ok { 474 | tags = append(tags, tsm[1]) 475 | tagsDup[tsm[1]] = struct{}{} 476 | } 477 | } 478 | } 479 | sort.Strings(tags) 480 | tags[0] = "None" 481 | 482 | selIdxChan, cleanUp, err := dialog.ListSelect(nc.Gui, "Select tag filter", tags, true) 483 | if err != nil { 484 | gocuiReturnError(nc.Gui, err) 485 | } 486 | go func() { 487 | idxs, ok := <-selIdxChan 488 | nc.Gui.Update(cleanUp) 489 | if ok { 490 | containsNone := false 491 | for _, v := range idxs { 492 | if v == 0 { 493 | containsNone = true 494 | break 495 | } 496 | } 497 | if len(idxs) == 0 || containsNone { 498 | nc.TitleFilter = nil 499 | } else { 500 | tagBuffer := strings.Builder{} 501 | for i, v := range idxs { 502 | tagBuffer.WriteString("\\[") 503 | tagBuffer.WriteString(regexp.QuoteMeta(tags[v])) 504 | tagBuffer.WriteString("\\]") 505 | if i < len(idxs)-1 { 506 | tagBuffer.WriteString("|") 507 | } 508 | } 509 | 510 | regex, err := regexp.Compile(tagBuffer.String()) 511 | if err != nil { 512 | gocuiReturnError(nc.Gui, err) 513 | } 514 | nc.TitleFilter = regex 515 | } 516 | nc.Gui.Update(func(gui *gocui.Gui) error { 517 | gui.DeleteView(ncInfoView) 518 | gui.DeleteView(ncResultsView) 519 | return nil 520 | }) 521 | } 522 | }() 523 | } 524 | 525 | var qualityTagRegex = `(\d{3,4}p)` 526 | 527 | func (nc *nyaaCui) FilterByQuality() { 528 | tags := make([]string, 1, len(nc.Results)+1) 529 | tagsDup := make(map[string]struct{}) 530 | re := regexp.MustCompile(qualityTagRegex) 531 | for _, result := range nc.Results { 532 | if tsm := re.FindStringSubmatch(result.Title); len(tsm) >= 2 && tsm[1] != "" { 533 | if _, ok := tagsDup[tsm[1]]; !ok { 534 | tags = append(tags, tsm[1]) 535 | tagsDup[tsm[1]] = struct{}{} 536 | } 537 | } 538 | } 539 | sort.Strings(tags) 540 | tags[0] = "None" 541 | 542 | selIdxChan, cleanUp, err := dialog.ListSelect(nc.Gui, "Select quality filter", tags, true) 543 | if err != nil { 544 | gocuiReturnError(nc.Gui, err) 545 | } 546 | go func() { 547 | idxs, ok := <-selIdxChan 548 | nc.Gui.Update(cleanUp) 549 | if ok { 550 | containsNone := false 551 | for _, v := range idxs { 552 | if v == 0 { 553 | containsNone = true 554 | break 555 | } 556 | } 557 | if len(idxs) == 0 || containsNone { 558 | nc.QualityFilter = nil 559 | } else { 560 | tagBuffer := strings.Builder{} 561 | for i, v := range idxs { 562 | tagBuffer.WriteString(regexp.QuoteMeta(tags[v])) 563 | if i < len(idxs)-1 { 564 | tagBuffer.WriteString("|") 565 | } 566 | } 567 | 568 | regex, err := regexp.Compile(tagBuffer.String()) 569 | if err != nil { 570 | gocuiReturnError(nc.Gui, err) 571 | } 572 | nc.QualityFilter = regex 573 | } 574 | nc.Gui.Update(func(gui *gocui.Gui) error { 575 | gui.DeleteView(ncInfoView) 576 | gui.DeleteView(ncResultsView) 577 | return nil 578 | }) 579 | } 580 | }() 581 | } 582 | 583 | func (nc *nyaaCui) setGuiKeyBindings(gui *gocui.Gui) { 584 | gui.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quitGocui) 585 | } 586 | 587 | func quitGocui(gui *gocui.Gui, view *gocui.View) error { 588 | return gocui.ErrQuit 589 | } 590 | 591 | func gocuiReturnError(gui *gocui.Gui, err error) { 592 | gui.Update(func(gui *gocui.Gui) error { 593 | return err 594 | }) 595 | } 596 | -------------------------------------------------------------------------------- /nyaa_scraper/nyaa.go: -------------------------------------------------------------------------------- 1 | package nyaa_scraper 2 | 3 | import ( 4 | "compress/gzip" 5 | "fmt" 6 | "github.com/PuerkitoBio/goquery" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "regexp" 11 | "strconv" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | type NyaaCategory struct { 17 | Name string 18 | Major int 19 | Minor int 20 | } 21 | 22 | func (category NyaaCategory) QueryParam() string { 23 | return fmt.Sprintf("c=%d_%d", category.Major, category.Minor) 24 | } 25 | 26 | func (category NyaaCategory) String() string { 27 | return category.Name 28 | } 29 | 30 | var ( 31 | AllCategories = NyaaCategory{"All categories", 0, 0} 32 | 33 | Anime = NyaaCategory{"All anime", 1, 0} 34 | AnimeMusicVideo = NyaaCategory{"Anime music video", 1, 1} 35 | AnimeEnglishTranslated = NyaaCategory{"Anime English translated", 1, 2} 36 | AnimeNonEnglishTranslated = NyaaCategory{"Anime non English translated", 1, 3} 37 | AnimeRaw = NyaaCategory{"Anime raw", 1, 4} 38 | 39 | Audio = NyaaCategory{"All Audio", 2, 0} 40 | AudioLossless = NyaaCategory{"Audio lossless", 2, 1} 41 | AudioLossy = NyaaCategory{"Audio lossy", 2, 2} 42 | 43 | Literature = NyaaCategory{"All literature", 3, 0} 44 | LiteratureEnglishTranslated = NyaaCategory{"Literature English translated", 3, 1} 45 | LiteratureNonEnglishTranslated = NyaaCategory{"Literature non English translated", 3, 2} 46 | LiteratureRaw = NyaaCategory{"Literature raw", 3, 3} 47 | 48 | LiveAction = NyaaCategory{"Live action all", 4, 0} 49 | LiveActionEnglishTranslated = NyaaCategory{"Live action English translated", 4, 1} 50 | LiveActionIdolOrPromotionalVideo = NyaaCategory{"Live action idol/promotional video", 4, 2} 51 | LiveActionNonEnglishTranslated = NyaaCategory{"Live action non English translated", 4, 3} 52 | LiveActionRaw = NyaaCategory{"Live action raw", 4, 4} 53 | 54 | Pictures = NyaaCategory{"Pictures all", 5, 0} 55 | PicturesGraphics = NyaaCategory{"Graphics", 5, 1} 56 | PicturesPhotos = NyaaCategory{"Photos", 5, 2} 57 | 58 | Software = NyaaCategory{"Software all", 6, 0} 59 | SoftwareApplications = NyaaCategory{"Applications", 6, 1} 60 | SoftwareGames = NyaaCategory{"Games", 6, 2} 61 | ) 62 | 63 | var Categories = []NyaaCategory{ 64 | AllCategories, 65 | Anime, 66 | AnimeMusicVideo, 67 | AnimeEnglishTranslated, 68 | AnimeNonEnglishTranslated, 69 | AnimeRaw, 70 | Audio, 71 | AudioLossless, 72 | AudioLossy, 73 | Literature, 74 | LiteratureEnglishTranslated, 75 | LiteratureNonEnglishTranslated, 76 | LiteratureRaw, 77 | LiveAction, 78 | LiveActionEnglishTranslated, 79 | LiveActionIdolOrPromotionalVideo, 80 | LiveActionNonEnglishTranslated, 81 | LiveActionRaw, 82 | Pictures, 83 | PicturesGraphics, 84 | PicturesPhotos, 85 | Software, 86 | SoftwareApplications, 87 | SoftwareGames, 88 | } 89 | 90 | func GetNyaaCategory(major, minor int) NyaaCategory { 91 | for _, c := range Categories { 92 | if c.Major == major && c.Minor == minor { 93 | return c 94 | } 95 | } 96 | return NyaaCategory{"Unknown", major, minor} 97 | } 98 | 99 | type NyaaFilter struct { 100 | Name string 101 | Val uint8 102 | } 103 | 104 | var ( 105 | NoFilter = NyaaFilter{"No filter", 0} 106 | NoRemakes = NyaaFilter{"No remakes", 1} 107 | TrustedOnly = NyaaFilter{"Trusted only", 2} 108 | ) 109 | 110 | func (filter NyaaFilter) QueryParam() string { 111 | return fmt.Sprintf("f=%d", filter.Val) 112 | } 113 | 114 | func (filter NyaaFilter) String() string { 115 | return filter.Name 116 | } 117 | 118 | var Filters = []NyaaFilter{ 119 | NoFilter, 120 | NoRemakes, 121 | TrustedOnly, 122 | } 123 | 124 | type NyaaClass uint8 125 | 126 | const ( 127 | Default NyaaClass = iota 128 | Trusted 129 | Danger 130 | ) 131 | 132 | func ParseNyaaClass(str string) NyaaClass { 133 | switch strings.ToLower(str) { 134 | case "success": 135 | return Trusted 136 | case "danger": 137 | return Danger 138 | default: 139 | return Default 140 | } 141 | } 142 | 143 | type NyaaEntry struct { 144 | Category NyaaCategory 145 | Class NyaaClass 146 | Title string 147 | TorrentLink string 148 | MagnetLink string 149 | Size string 150 | DateAdded time.Time 151 | Seeders int 152 | Leechers int 153 | CompletedDownloads int 154 | } 155 | 156 | const nyaaQueryPattern = "https://nyaa.si/?%s&%s&p=%d&q=%s" 157 | 158 | type NyaaResultPage struct { 159 | DisplayedFrom int 160 | DisplayedTo int 161 | DisplayedOutOf int 162 | 163 | Results []NyaaEntry 164 | } 165 | 166 | func Search(query string, category NyaaCategory, filter NyaaFilter) (NyaaResultPage, error) { 167 | return SearchSpecificPage(query, category, filter, 1) 168 | } 169 | 170 | func SearchSpecificPage(query string, category NyaaCategory, filter NyaaFilter, page int) (NyaaResultPage, error) { 171 | resultPage := NyaaResultPage{} 172 | 173 | address := fmt.Sprintf(nyaaQueryPattern, filter.QueryParam(), category.QueryParam(), 174 | page, url.QueryEscape(query)) 175 | respBody, err := doRequest(address) 176 | if err != nil { 177 | if respBody != nil { 178 | respBody.Close() 179 | } 180 | return resultPage, fmt.Errorf("request failed: %v", err) 181 | } 182 | defer respBody.Close() 183 | 184 | doc, err := goquery.NewDocumentFromReader(respBody) 185 | if err != nil { 186 | return resultPage, fmt.Errorf("error parsing response: %v", err) 187 | } 188 | 189 | rows := doc.Find(".torrent-list > tbody").Children() 190 | resultPage.Results = make([]NyaaEntry, rows.Size()) 191 | 192 | rows.Each(func(i int, sel *goquery.Selection) { 193 | resultPage.Results[i] = *parseNyaaEntry(sel) 194 | }) 195 | 196 | info := strings.TrimSpace(doc.Find("div .pagination-page-info").Text()) 197 | 198 | re := regexp.MustCompile("[0-9]+") 199 | numbers := re.FindAllString(info, -1) 200 | 201 | if len(numbers) < 3 { 202 | return resultPage, fmt.Errorf("regexp failed (returned less than 3 resutls)") 203 | } 204 | resultPage.DisplayedFrom, _ = strconv.Atoi(numbers[0]) 205 | resultPage.DisplayedTo, _ = strconv.Atoi(numbers[1]) 206 | resultPage.DisplayedOutOf, _ = strconv.Atoi(numbers[2]) 207 | 208 | return resultPage, nil 209 | } 210 | 211 | func parseNyaaEntry(sel *goquery.Selection) *NyaaEntry { 212 | entry := NyaaEntry{} 213 | 214 | entry.Class = ParseNyaaClass(sel.AttrOr("class", "default")) 215 | 216 | currChild := sel.Children().First() 217 | category := currChild.Find("a").AttrOr("href", "") 218 | major, _ := strconv.Atoi(category[4:5]) 219 | minor, _ := strconv.Atoi(category[6:7]) 220 | entry.Category = GetNyaaCategory(major, minor) 221 | 222 | currChild = currChild.Next() 223 | entry.Title = currChild.Find("a").Last().AttrOr("title", "") 224 | 225 | currChild = currChild.Next() 226 | links := currChild.Find("a") 227 | entry.TorrentLink = "https://nyaa.si" + links.AttrOr("href", "") 228 | entry.MagnetLink = links.Next().AttrOr("href", "") 229 | 230 | currChild = currChild.Next() 231 | entry.Size = strings.TrimSpace(currChild.Text()) 232 | 233 | currChild = currChild.Next() 234 | timestamp, _ := strconv.Atoi(currChild.AttrOr("data-timestamp", "0")) 235 | entry.DateAdded = time.Unix(int64(timestamp), 0) 236 | 237 | currChild = currChild.Next() 238 | seeders, _ := strconv.Atoi(currChild.Text()) 239 | entry.Seeders = seeders 240 | 241 | currChild = currChild.Next() 242 | leechers, _ := strconv.Atoi(currChild.Text()) 243 | entry.Leechers = leechers 244 | 245 | currChild = currChild.Next() 246 | completedDownloads, _ := strconv.Atoi(currChild.Text()) 247 | entry.CompletedDownloads = completedDownloads 248 | 249 | return &entry 250 | } 251 | 252 | //Note: do not forget to close the returned ReadCloser 253 | func doRequest(address string) (io.ReadCloser, error) { 254 | req, err := http.NewRequest(http.MethodGet, address, nil) 255 | if err != nil { 256 | return nil, err 257 | } 258 | 259 | req.Header.Add("Accept-Encoding", "gzip") 260 | 261 | resp, err := http.DefaultClient.Do(req) 262 | if err != nil { 263 | return nil, err 264 | } 265 | if resp.StatusCode != 200 { 266 | return nil, fmt.Errorf("searching failed; server returned: %s", resp.Status) 267 | } 268 | 269 | return gzip.NewReader(resp.Body) 270 | } 271 | -------------------------------------------------------------------------------- /oauth2/implicit_grant.go: -------------------------------------------------------------------------------- 1 | package oauth2 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/skratchdot/open-golang/open" 7 | "net/http" 8 | "strconv" 9 | "time" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type OAuthToken struct { 14 | ClientID uint 15 | 16 | Token string 17 | Type string 18 | ExpireDate time.Time 19 | } 20 | 21 | func OAuthImplicitGrantAuth(url, browserPath string, clientID uint, listenPort int) (OAuthToken, error) { 22 | tokenC := make(chan OAuthToken) 23 | 24 | listenPortStr := strconv.Itoa(listenPort) 25 | srv := http.Server{Addr: ":" + listenPortStr} 26 | http.HandleFunc("/oauth2", func(w http.ResponseWriter, r *http.Request) { 27 | website := ` 28 | 29 | 30 | 31 | 32 | 33 | 34 | 41 | 42 | 43 | ` 44 | w.Write([]byte(website)) 45 | }) 46 | http.HandleFunc("/oauth2parsed", func(w http.ResponseWriter, r *http.Request) { 47 | if err := r.ParseForm(); err != nil { 48 | w.Write([]byte(err.Error())) 49 | return 50 | } 51 | token := OAuthToken{} 52 | token.ClientID = clientID 53 | token.Token = r.Form.Get("access_token") 54 | token.Type = r.Form.Get("token_type") 55 | 56 | expiresIn, _ := time.ParseDuration(r.Form.Get("expires_in") + "s") 57 | token.ExpireDate = time.Now().Add(expiresIn) 58 | 59 | if token.Token == "" { 60 | w.Write([]byte("No token received")) 61 | return 62 | } 63 | w.Write([]byte("Token retrieved successfully")) 64 | tokenC <- token 65 | }) 66 | 67 | go func() { 68 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 69 | panic(fmt.Errorf("HTTP server error: %v\n", err)) 70 | } 71 | }() 72 | 73 | authUrl := fmt.Sprintf("%s?client_id=%d&response_type=token", url, clientID) 74 | 75 | if browserPath == "" { 76 | if err := open.Start(authUrl); err != nil { 77 | return OAuthToken{}, errors.Wrap(err, "opening browser error") 78 | } 79 | } else { 80 | if err := open.StartWith(authUrl, browserPath); err != nil { 81 | return OAuthToken{}, errors.Wrap(err, "opening browser error") 82 | } 83 | } 84 | 85 | token := <-tokenC 86 | 87 | if err := srv.Shutdown(context.Background()); err != nil { 88 | return token, err 89 | } 90 | 91 | return token, nil 92 | } 93 | -------------------------------------------------------------------------------- /search_cui.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/aqatl/mal/anilist" 9 | "github.com/aqatl/mal/dialog" 10 | "github.com/fatih/color" 11 | "github.com/jroimartin/gocui" 12 | "github.com/urfave/cli" 13 | ) 14 | 15 | func alSearch(ctx *cli.Context) error { 16 | al, err := loadAniList(ctx) 17 | if err != nil { 18 | return nil 19 | } 20 | 21 | searchQuery := strings.TrimSpace(strings.Join(ctx.Args(), " ")) 22 | results, err := anilist.Search(searchQuery, 1, 50, anilist.Anime, al.Token) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | descriptionReplacer := strings.NewReplacer("
", "") 28 | for i := range results { 29 | results[i].Description = descriptionReplacer.Replace(results[i].Description) 30 | } 31 | 32 | gui, err := gocui.NewGui(gocui.OutputNormal) 33 | defer gui.Close() 34 | if err != nil { 35 | return fmt.Errorf("gocui error: %v", err) 36 | } 37 | 38 | sc := &searchCui{ 39 | Al: al, 40 | Gui: gui, 41 | SearchQuery: searchQuery, 42 | Results: results, 43 | Mode: scListView, 44 | } 45 | 46 | gui.SetManager(sc) 47 | sc.setGuiKeyBindings(gui) 48 | 49 | gui.Mouse = false 50 | gui.Highlight = true 51 | gui.Cursor = false 52 | gui.SelFgColor = gocui.ColorGreen 53 | 54 | if err = gui.MainLoop(); err != nil && err != gocui.ErrQuit { 55 | return err 56 | } 57 | return nil 58 | } 59 | 60 | const ( 61 | scFiltersView = "ncFiltersView" 62 | scSearchView = "scSearchView" 63 | scShortcutsView = "scShortcutsView" 64 | ) 65 | 66 | type searchCuiMode uint8 67 | 68 | const ( 69 | scListView searchCuiMode = iota 70 | scFullDetailsView 71 | ) 72 | 73 | type searchCui struct { 74 | Al *AniList 75 | Gui *gocui.Gui 76 | 77 | SearchQuery string 78 | Results []anilist.MediaFull 79 | 80 | Mode searchCuiMode 81 | SelIdx int 82 | Origin int 83 | } 84 | 85 | var searchResultHighlight = color.New(color.FgBlack, color.BgYellow) 86 | var yellowC = color.New(color.FgYellow, color.Bold) 87 | var cyanC = color.New(color.FgCyan, color.Bold) 88 | 89 | // Safe to call from another goroutine 90 | func (sc *searchCui) reload() { 91 | results, err := anilist.Search(sc.SearchQuery, 1, 50, anilist.Anime, sc.Al.Token) 92 | if err != nil { 93 | dialog.JustShowOkDialog(sc.Gui, "Error", 94 | strings.TrimSpace(strings.Replace(err.Error(), "\n", " ", -1))) 95 | } 96 | sc.Gui.Update(func(gui *gocui.Gui) error { 97 | sc.Results = results 98 | sc.Gui.SetManager(sc) 99 | sc.setGuiKeyBindings(sc.Gui) 100 | return nil 101 | }) 102 | } 103 | 104 | func (sc *searchCui) setGuiKeyBindings(gui *gocui.Gui) { 105 | gui.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quitGocui) 106 | } 107 | 108 | func (sc *searchCui) Layout(gui *gocui.Gui) error { 109 | switch sc.Mode { 110 | case scListView: 111 | return sc.listLayout() 112 | case scFullDetailsView: 113 | return sc.fullDetailsLayout() 114 | default: 115 | return fmt.Errorf("invalid mode: %d", sc.Mode) 116 | } 117 | } 118 | 119 | func (sc *searchCui) listLayout() error { 120 | w, h := sc.Gui.Size() 121 | h -= 4 122 | 123 | if err := sc.filtersView(); err != nil { 124 | return err 125 | } 126 | if err := sc.searchView(); err != nil { 127 | return err 128 | } 129 | y := 4 130 | for i := sc.Origin; i < len(sc.Results) && y < h; i++ { 131 | result := &sc.Results[i] 132 | 133 | if v, err := sc.Gui.SetView(strconv.Itoa(result.Id), 0, y, w-1, y+7); err != nil { 134 | if err != gocui.ErrUnknownView { 135 | return err 136 | } 137 | 138 | v.Frame = false 139 | v.Wrap = true 140 | v.Highlight = false 141 | v.Editable = true 142 | v.Editor = sc 143 | sc.Gui.SetViewOnTop(v.Name()) 144 | 145 | if i == sc.SelIdx { 146 | searchResultHighlight.Fprintf(v, "%s (%s)\n", result.Title.Romaji, result.Title.English) 147 | } else { 148 | yellowC.Fprintf(v, "%s (%s)\n", result.Title.Romaji, result.Title.English) 149 | } 150 | cyanC.Fprint(v, strings.ToLower( 151 | fmt.Sprintf("%s | %s | %d eps | %s %d | %d%% | %v\n", 152 | result.Format, 153 | result.Status, 154 | result.Episodes, 155 | result.Season, 156 | result.StartDate.Year, 157 | result.AverageScore, 158 | result.Genres, 159 | ))) 160 | fmt.Fprintln(v, result.Description) 161 | 162 | } 163 | y += 6 164 | } 165 | 166 | if len(sc.Results) > 0 { 167 | sc.Gui.SetCurrentView(strconv.Itoa(sc.Results[0].Id)) 168 | } 169 | 170 | return nil 171 | } 172 | 173 | func (sc *searchCui) fullDetailsLayout() error { 174 | w, h := sc.Gui.Size() 175 | 176 | if err := sc.filtersView(); err != nil { 177 | return err 178 | } 179 | 180 | if v, err := sc.Gui.SetView(strconv.Itoa(sc.Results[sc.SelIdx].Id), 0, 5, w-1, h-1); err != nil { 181 | if err != gocui.ErrUnknownView { 182 | return err 183 | } 184 | 185 | v.Wrap = true 186 | v.Editor = sc 187 | v.Editable = true 188 | 189 | sc.Gui.SetCurrentView(v.Name()) 190 | 191 | fmt.Fprintln(v, sc.Results[sc.SelIdx].Description) 192 | } 193 | 194 | return nil 195 | } 196 | 197 | func (sc *searchCui) filtersView() error { 198 | w, _ := sc.Gui.Size() 199 | v, err := sc.Gui.SetView(scFiltersView, 0, 0, w-1, 4) 200 | if err != nil { 201 | if err != gocui.ErrUnknownView { 202 | return err 203 | } 204 | 205 | v.Editor = sc 206 | 207 | fmt.Fprintln(v, "Search:", sc.SearchQuery) 208 | fmt.Fprintln(v, "Results:", len(sc.Results)) 209 | } 210 | 211 | return nil 212 | } 213 | 214 | func (sc *searchCui) searchView() error { 215 | w, h := sc.Gui.Size() 216 | v, err := sc.Gui.SetView(scSearchView, 0, h-3, w-1, h-1) 217 | if err != nil { 218 | if err != gocui.ErrUnknownView { 219 | return err 220 | } 221 | 222 | v.Title = "Search" 223 | v.Frame = true 224 | v.Editor = sc.searchViewEditor() 225 | 226 | fmt.Fprint(v, sc.SearchQuery) 227 | } 228 | 229 | return nil 230 | } 231 | 232 | func (sc *searchCui) searchViewEditor() gocui.Editor { 233 | return gocui.EditorFunc(func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 234 | gocui.DefaultEditor.Edit(v, key, ch, mod) 235 | go sc.reload() 236 | }) 237 | } 238 | 239 | func (sc *searchCui) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { 240 | switch sc.Mode { 241 | case scListView: 242 | switch { 243 | case ch == 'j' || key == gocui.KeyArrowDown: 244 | sc.nextResult() 245 | case ch == 'k' || key == gocui.KeyArrowUp: 246 | sc.previousResult() 247 | case key == gocui.KeyEnter: 248 | if len(sc.Results) == 0 || sc.SelIdx < 0 || sc.SelIdx > len(sc.Results)-1 { 249 | return 250 | } 251 | sc.Mode = scFullDetailsView 252 | for _, result := range sc.Results { 253 | sc.Gui.DeleteView(strconv.Itoa(result.Id)) 254 | } 255 | } 256 | case scFullDetailsView: 257 | switch { 258 | case ch == 'j' || key == gocui.KeyArrowDown: 259 | sc.nextResult() 260 | case ch == 'k' || key == gocui.KeyArrowUp: 261 | sc.previousResult() 262 | case key == gocui.KeyEnter: 263 | sc.Mode = scListView 264 | sc.Gui.DeleteView(strconv.Itoa(sc.Results[sc.SelIdx].Id)) 265 | case ch == 'a': 266 | if len(sc.Results) == 0 { 267 | return 268 | } 269 | sc.addEntry() 270 | } 271 | } 272 | } 273 | 274 | func (sc *searchCui) nextResult() { 275 | if sc.SelIdx != len(sc.Results)-1 { 276 | sc.SelIdx++ 277 | if _, h := sc.Gui.Size(); sc.SelIdx > (sc.Origin + int((h-6)/6)) { 278 | sc.Origin++ 279 | } 280 | sc.Gui.DeleteView(strconv.Itoa(sc.Results[sc.SelIdx].Id)) 281 | sc.Gui.DeleteView(strconv.Itoa(sc.Results[sc.SelIdx-1].Id)) 282 | } 283 | } 284 | 285 | func (sc *searchCui) previousResult() { 286 | if sc.SelIdx != 0 { 287 | sc.SelIdx-- 288 | if sc.SelIdx < sc.Origin { 289 | sc.Origin-- 290 | } 291 | sc.Gui.DeleteView(strconv.Itoa(sc.Results[sc.SelIdx].Id)) 292 | sc.Gui.DeleteView(strconv.Itoa(sc.Results[sc.SelIdx+1].Id)) 293 | } 294 | } 295 | 296 | func (sc *searchCui) addEntry() { 297 | if entry := sc.Al.GetMediaListById(sc.Results[sc.SelIdx].Id); entry != nil { 298 | dialog.JustShowOkDialog(sc.Gui, "Add entry", 299 | "Entry already added (on list "+entry.Status.String()+")") 300 | return 301 | } 302 | 303 | entry, err := anilist.AddMediaListEntry(sc.Results[sc.SelIdx].Id, anilist.Planning, sc.Al.Token) 304 | if err != nil { 305 | dialog.JustShowOkDialog(sc.Gui, "Error", err.Error()) 306 | return 307 | } 308 | dialog.JustShowOkDialog(sc.Gui, "Success", entry.Title.UserPreferred+" added") 309 | 310 | sc.Al.List = append(sc.Al.List, entry) 311 | sc.Gui.Update(func(gui *gocui.Gui) error { 312 | return saveAniListAnimeLists(sc.Al) 313 | }) 314 | } 315 | -------------------------------------------------------------------------------- /templates.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aqatl/mal/mal" 5 | "math" 6 | "text/template" 7 | ) 8 | 9 | const PrettyListTemplate = `No{{printf "%57s" "Title"}}{{printf "%8s" "Eps"}}{{printf "%6s" "Score"}}{{printf "%7s" "ID"}} 10 | ================================================================================{{range $index, $var := .List}} 11 | {{if eq .ID $.SelectedID}}{{"\033[93;1m"}}{{end}}{{len $.List | minus $index | abs | printf "%2d"}}{{.Title | printf " %56.56s"}}{{printf "%d/%d" .WatchedEpisodes .Episodes | printf "%8s"}}{{.MyScore | printf "%6d"}}{{.ID | printf "%7d"}}{{if eq .ID $.SelectedID}}{{"\033[0m "}}{{end}}{{end}} 12 | ` 13 | 14 | var PrettyList = template.Must( 15 | template.New("prettyList"). 16 | Funcs(template.FuncMap{ 17 | "plus": func(a, b int) int { 18 | return a + b 19 | }, 20 | "minus": func(a, b int) int { 21 | return a - b 22 | }, 23 | "abs": func(a int) int { 24 | return int(math.Abs(float64(a))) 25 | }, 26 | }). 27 | Parse(PrettyListTemplate), 28 | ) 29 | 30 | type PrettyListData struct { 31 | List []*mal.Anime 32 | SelectedID int 33 | } 34 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/user" 10 | "path/filepath" 11 | "time" 12 | 13 | "github.com/aqatl/mal/anilist" 14 | "github.com/aqatl/mal/mal" 15 | "github.com/fatih/color" 16 | ) 17 | 18 | func basicAuth(username, password string) string { 19 | return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) 20 | } 21 | 22 | func reverseAnimeSlice(s []*mal.Anime) { 23 | last := len(s) - 1 24 | for i := 0; i < len(s)/2; i++ { 25 | s[i], s[last-i] = s[last-i], s[i] 26 | } 27 | } 28 | 29 | func getDataDir() string { 30 | // Check for old cache dir at $HOME/.mal 31 | if usr, err := user.Current(); err == nil { 32 | dir := filepath.Join(usr.HomeDir, ".mal") 33 | if _, err := os.Stat(dir); err == nil { 34 | return dir 35 | } else { 36 | if !os.IsNotExist(err) { 37 | log.Printf("Error probing for %s: %v", dir, err) 38 | } 39 | } 40 | } else { 41 | log.Printf("Error getting current user: %v. ignoring", err) 42 | } 43 | 44 | // Old cache dir not present, use user config dir 45 | dataDir, err := os.UserConfigDir() 46 | if err != nil { 47 | log.Printf("Error getting user config dir: %v", err) 48 | return "" 49 | } 50 | 51 | return filepath.Join(dataDir, "mal") 52 | } 53 | 54 | func chooseStrFromSlice(alts []string) string { 55 | if length := len(alts); length == 1 { 56 | return alts[0] 57 | } else if length == 0 { 58 | return "" 59 | } 60 | 61 | for i, synonym := range alts { 62 | fmt.Printf("%2d. %s\n", i+1, synonym) 63 | } 64 | 65 | idx := 0 66 | scan := func() { 67 | fmt.Scan(&idx) 68 | } 69 | for scan(); idx <= 0 || idx > len(alts); { 70 | fmt.Print("\rInvalid input. Try again: ") 71 | scan() 72 | } 73 | 74 | return alts[idx-1] 75 | } 76 | 77 | func printEntryDetails(title, status string, watchedEps, eps int, score float32, scoreFormat anilist.ScoreFormat, lastUpdated time.Time) { 78 | var scorePattern string 79 | if scoreFormat == anilist.Point10Decimal { 80 | scorePattern = "%.1f" 81 | } else { 82 | scorePattern = "%.f" 83 | } 84 | 85 | titleStr := color.HiYellowString("%s", title) 86 | episodesStr := color.HiRedString("%d/%d", watchedEps, eps) 87 | scoreStr := color.HiRedString(scorePattern, score) 88 | statusStr := color.HiRedString("%s", status) 89 | lastUpdatedStr := color.HiRedString("%v", lastUpdated) 90 | 91 | fmt.Fprintf( 92 | color.Output, 93 | "Title: %s\n"+ 94 | "Episodes: %s\n"+ 95 | "Score: %s\n"+ 96 | "Status: %v\n"+ 97 | "Last updated: %v\n", 98 | titleStr, 99 | episodesStr, 100 | scoreStr, 101 | statusStr, 102 | lastUpdatedStr, 103 | ) 104 | } 105 | 106 | func printEntryDetailsAfterUpdatedEpisodes(title, status string, epsBefore, epsNow, eps int, score float32, scoreFormat anilist.ScoreFormat, lastUpdated time.Time) { 107 | var scorePattern string 108 | if scoreFormat == anilist.Point10Decimal { 109 | scorePattern = "%.1f" 110 | } else { 111 | scorePattern = "%.f" 112 | } 113 | 114 | titleStr := color.HiYellowString("%s", title) 115 | episodesBeforeStr := color.HiRedString("%d/%d", epsBefore, eps) 116 | episodesAfterStr := color.HiRedString("%d/%d", epsNow, eps) 117 | scoreStr := color.HiRedString(scorePattern, score) 118 | statusStr := color.HiRedString("%s", status) 119 | lastUpdatedStr := color.HiRedString("%v", lastUpdated) 120 | 121 | fmt.Fprintf( 122 | color.Output, 123 | "Title: %s\n"+ 124 | "Episodes: %s -> %s\n"+ 125 | "Score: %s\n"+ 126 | "Status: %v\n"+ 127 | "Last updated: %v\n", 128 | titleStr, 129 | episodesBeforeStr, 130 | episodesAfterStr, 131 | scoreStr, 132 | statusStr, 133 | lastUpdatedStr, 134 | ) 135 | } 136 | 137 | func malPrintEntryDetails(entry *mal.Anime) { 138 | printEntryDetails( 139 | entry.Title, 140 | entry.MyStatus.String(), 141 | entry.WatchedEpisodes, 142 | entry.Episodes, 143 | float32(int(entry.MyScore)), 144 | anilist.Point10, 145 | time.Unix(entry.LastUpdated, 0)) 146 | } 147 | 148 | func malPrintEntryDetailsAfterUpdatedEpisodes(entry *mal.Anime, epsBefore int) { 149 | printEntryDetailsAfterUpdatedEpisodes( 150 | entry.Title, 151 | entry.MyStatus.String(), 152 | epsBefore, 153 | entry.WatchedEpisodes, 154 | entry.Episodes, 155 | float32(int(entry.MyScore)), 156 | anilist.Point10, 157 | time.Unix(entry.LastUpdated, 0)) 158 | } 159 | 160 | func alPrintEntryDetails(entry *anilist.MediaListEntry, scoreFormat anilist.ScoreFormat) { 161 | printEntryDetails(entry.Title.UserPreferred, 162 | entry.Status.String(), 163 | entry.Progress, 164 | entry.Episodes, 165 | entry.Score, 166 | scoreFormat, 167 | time.Unix(int64(entry.UpdatedAt), 0)) 168 | } 169 | 170 | func alPrintEntryDetailsAfterUpdatedEpisodes(entry *anilist.MediaListEntry, epsBefore int, scoreFormat anilist.ScoreFormat) { 171 | printEntryDetailsAfterUpdatedEpisodes( 172 | entry.Title.UserPreferred, 173 | entry.Status.String(), 174 | epsBefore, 175 | entry.Progress, 176 | entry.Episodes, 177 | entry.Score, 178 | scoreFormat, 179 | time.Unix(int64(entry.UpdatedAt), 0)) 180 | } 181 | 182 | // Returns true if file was loaded correctly 183 | func LoadJsonFile(file string, i interface{}) bool { 184 | f, err := os.Open(file) 185 | defer f.Close() 186 | if err == nil { 187 | err = json.NewDecoder(f).Decode(i) 188 | if err == nil { 189 | return true 190 | } else { 191 | panic(err) 192 | } 193 | } 194 | if os.IsNotExist(err) { 195 | return false 196 | } 197 | panic(err) 198 | } 199 | 200 | func SaveJsonFile(file string, i interface{}) error { 201 | f, err := os.Create(file) 202 | defer f.Close() 203 | if err != nil { 204 | return err 205 | } 206 | return json.NewEncoder(f).Encode(i) 207 | } 208 | --------------------------------------------------------------------------------