├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── content_manager ├── common.go ├── download.go └── stream.go ├── db ├── connection.go ├── controller.go └── models.go ├── go.mod ├── go.sum ├── gui └── app │ ├── explorer │ ├── library │ │ ├── album.go │ │ ├── albums.go │ │ ├── artist.go │ │ ├── artists.go │ │ ├── main.go │ │ ├── play_all.go │ │ ├── tag.go │ │ ├── tags.go │ │ └── tracks.go │ ├── main.go │ ├── queue.go │ └── search.go │ ├── main.go │ └── player │ ├── bottom.go │ ├── main.go │ └── top.go ├── hook ├── hooker.go └── hooks.go ├── main.go ├── player ├── events.go ├── loop_status.go ├── main.go ├── mpris.go ├── mpv │ ├── consts.go │ ├── error.go │ ├── mpv.go │ ├── node.go │ └── node_test.go └── state.go ├── providers ├── track_info.go └── youtube │ ├── search.go │ └── youtube_dl.go ├── trayicon ├── icon │ ├── icon.go │ └── icon.png └── tray.go ├── utils ├── fmt.go ├── fs.go └── logger.go └── version ├── migrate.go └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | neptune 2 | neptune.exe 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | go build -v 3 | 4 | # requires a valid cross-compiling environment 5 | windows: 6 | PKG_CONFIG_PATH=/usr/x86_64-w64-mingw32/lib/pkgconfig CGO_ENABLED=1 CC=x86_64-w64-mingw32-cc GOOS=windows GOARCH=amd64 go build -ldflags -H=windowsgui -v 7 | 8 | run: build 9 | ./neptune 10 | 11 | install: build 12 | sudo cp ./neptune /usr/bin/ 13 | 14 | tidy: 15 | go mod tidy 16 | 17 | # (build but with a smaller binary) 18 | dist: 19 | go build -ldflags="-w -s" -gcflags=all=-l -v 20 | 21 | # (even smaller binary) 22 | pack: dist 23 | upx ./neptune 24 | 25 | windows_pack: windows 26 | upx ./neptune.exe 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NEPTUNE 2 | 3 | A **Work In Progress** YouTube based song player, made with libmpv, GTK and GoLang. 4 | 5 | _If you want to listen to song inside your terminal, take a look at 6 | [Tuner](https://github.com/Pauloo27/tuner)._ 7 | 8 | ## Installing 9 | ### Arch Linux 10 | You can install Neptune from the AUR, the package is called `neptune-git`. 11 | 12 | ### Other distros 13 | First, install the required packages to build Neptune. They are: 14 | - the GoLang compiler; 15 | - mpv (with libmpv); 16 | - GCC, libgtk 3 and libappindicator for gtk3 (required by the systray package); 17 | - YouTube-DL (it's not required to compile, but it's required to run); 18 | 19 | _if you want a MPRIS integration, install mpv-mpris_. 20 | 21 | Then, clone the repository and run `make install`. 22 | 23 | ## License 24 | 25 | GPL Logo 26 | 27 | This project is licensed under [GNU General Public License v2.0](./LICENSE). 28 | 29 | This program is free software; you can redistribute it and/or modify 30 | it under the terms of the GNU General Public License as published by 31 | the Free Software Foundation; either version 2 of the License, or 32 | (at your option) any later version. 33 | 34 | This program is distributed in the hope that it will be useful, 35 | but WITHOUT ANY WARRANTY; without even the implied warranty of 36 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 37 | GNU General Public License for more details. 38 | -------------------------------------------------------------------------------- /content_manager/common.go: -------------------------------------------------------------------------------- 1 | package content_manager 2 | 3 | import ( 4 | "os" 5 | "path" 6 | 7 | "github.com/Pauloo27/neptune/db" 8 | "github.com/Pauloo27/neptune/player" 9 | "github.com/Pauloo27/neptune/providers" 10 | "github.com/Pauloo27/neptune/providers/youtube" 11 | "github.com/Pauloo27/neptune/utils" 12 | ) 13 | 14 | func fetchVideoInfo(result *youtube.YoutubeEntry, download bool) (*youtube.VideoInfo, error) { 15 | player.State.Fetching = result 16 | var info *youtube.VideoInfo 17 | var err error 18 | if download { 19 | tmpFile := path.Join(utils.GetTmpFolder(), result.ID+".m4a") 20 | info, err = youtube.FetchInfoAndDownload(result, tmpFile) 21 | if err != nil { 22 | return nil, err 23 | } 24 | } else { 25 | info, err = youtube.FetchInfo(result) 26 | if err != nil { 27 | return nil, err 28 | } 29 | } 30 | return info, nil 31 | } 32 | 33 | func store(result *youtube.YoutubeEntry, download bool) { 34 | player.ClearQueue() 35 | 36 | track, _ := db.FindTrackByEntry(result) 37 | if track != nil { 38 | player.PlayTrack(track) 39 | return 40 | } 41 | go func() { 42 | videoInfo, err := fetchVideoInfo(result, download) 43 | utils.HandleError(err, "Cannot fetch video info") 44 | 45 | var trackInfo *providers.TrackInfo 46 | if videoInfo.Artist == "" || videoInfo.Track == "" { 47 | artistInfo := providers.ArtistInfo{ 48 | Name: videoInfo.Uploader, 49 | MBID: "!YT:" + videoInfo.UploaderID, 50 | } 51 | albumInfo := providers.AlbumInfo{ 52 | Title: "YouTube videos by " + videoInfo.Uploader, 53 | MBID: "!YT:" + videoInfo.UploaderID, 54 | ImageURL: videoInfo.GetThumbnail(), 55 | } 56 | trackInfo = &providers.TrackInfo{ 57 | Artist: &artistInfo, 58 | Album: &albumInfo, 59 | Title: videoInfo.Title, 60 | MBID: "!YT:" + videoInfo.ID, 61 | } 62 | } else { 63 | trackInfo, err = providers.FetchTrackInfo(videoInfo) 64 | utils.HandleError(err, "Cannot fetch track info") 65 | if trackInfo.Album.ImageURL == "" { 66 | trackInfo.Album.ImageURL = videoInfo.GetThumbnail() 67 | } 68 | } 69 | 70 | track, err := db.StoreTrack(videoInfo, trackInfo, download) 71 | utils.HandleError(err, "Cannot store track to db") 72 | 73 | // create album folder 74 | os.MkdirAll(track.Album.GetAlbumPath(), 0744) 75 | 76 | // download album art 77 | utils.DownloadFile(trackInfo.Album.ImageURL, track.Album.GetAlbumArtPath()) 78 | 79 | // move file (download to the temp folder) to the album folder 80 | err = os.Rename(path.Join(utils.GetTmpFolder(), result.ID+".m4a"), track.GetLocalPath()) 81 | utils.HandleError(err, "Cannot move tmp download file to cache file") 82 | 83 | // start 84 | player.PlayTrack(track) 85 | }() 86 | } 87 | -------------------------------------------------------------------------------- /content_manager/download.go: -------------------------------------------------------------------------------- 1 | package content_manager 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/providers/youtube" 5 | ) 6 | 7 | func Download(result *youtube.YoutubeEntry) { 8 | store(result, true) 9 | } 10 | -------------------------------------------------------------------------------- /content_manager/stream.go: -------------------------------------------------------------------------------- 1 | package content_manager 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/providers/youtube" 5 | ) 6 | 7 | func Stream(result *youtube.YoutubeEntry) { 8 | store(result, false) 9 | } 10 | -------------------------------------------------------------------------------- /db/connection.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "path" 5 | 6 | "github.com/Pauloo27/neptune/utils" 7 | "gorm.io/driver/sqlite" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | var Database *gorm.DB 12 | var DataFolder string 13 | 14 | func Connect(dataFolder string) { 15 | DataFolder = dataFolder 16 | db, err := gorm.Open(sqlite.Open(path.Join(dataFolder, "db.sqlite")), &gorm.Config{}) 17 | utils.HandleError(err, "Cannot connect to db") 18 | Database = db 19 | 20 | Database.AutoMigrate(&NeptuneVersion{}) 21 | Database.AutoMigrate(&Artist{}) 22 | Database.AutoMigrate(&Album{}) 23 | Database.AutoMigrate(&Tag{}) 24 | Database.AutoMigrate(&TrackTag{}) 25 | Database.AutoMigrate(&Track{}) 26 | } 27 | -------------------------------------------------------------------------------- /db/controller.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/Pauloo27/neptune/providers" 7 | "github.com/Pauloo27/neptune/providers/youtube" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | const ( 12 | PAGE_SIZE = 50 13 | ) 14 | 15 | func CountPlayFor(track *Track) error { 16 | return Database.Model(track).Update("play_count", gorm.Expr("play_count + 1")).Error 17 | } 18 | 19 | func FindTrackByEntry(result *youtube.YoutubeEntry) (*Track, error) { 20 | var track Track 21 | err := Database. 22 | Preload("Album.Artist").Preload("Tags.Tag"). 23 | First(&track, "youtube_id = ?", result.ID).Error 24 | 25 | if err != nil { 26 | if errors.Is(gorm.ErrRecordNotFound, err) { 27 | return nil, nil 28 | } 29 | } 30 | return &track, err 31 | } 32 | 33 | func ListAllTracks() ([]*Track, error) { 34 | var tracks []*Track 35 | 36 | result := Database. 37 | Preload("Album.Artist").Preload("Tags.Tag"). 38 | Order("title collate nocase asc"). 39 | Find(&tracks) 40 | 41 | return tracks, result.Error 42 | } 43 | 44 | func ListTracks(page int) ([]*Track, error) { 45 | var tracks []*Track 46 | 47 | result := Database. 48 | Preload("Album.Artist").Preload("Tags.Tag"). 49 | Order("title collate nocase asc"). 50 | Limit(PAGE_SIZE). 51 | Offset(page * PAGE_SIZE). 52 | Find(&tracks) 53 | 54 | return tracks, result.Error 55 | } 56 | 57 | func ListArtists() ([]*Artist, error) { 58 | var artists []*Artist 59 | 60 | result := Database.Order("name collate nocase asc").Find(&artists) 61 | 62 | return artists, result.Error 63 | } 64 | 65 | func ListAlbumsBy(artist *Artist) ([]*Album, error) { 66 | var albums []*Album 67 | 68 | result := Database.Preload("Artist").Find(&albums, "artist_id = ?", artist.ID) 69 | 70 | return albums, result.Error 71 | } 72 | 73 | func ListAlbums() ([]*Album, error) { 74 | var albums []*Album 75 | 76 | result := Database.Preload("Artist").Order("title collate nocase asc").Find(&albums) 77 | 78 | return albums, result.Error 79 | } 80 | 81 | func ListTracksBy(artist *Artist) ([]*Track, error) { 82 | var tracks []*Track 83 | 84 | result := Database. 85 | Preload("Album.Artist").Preload("Tags.Tag").Joins("Album"). 86 | Order("tracks.title collate nocase asc").Find(&tracks, "Album__artist_id = ?", artist.ID) 87 | 88 | return tracks, result.Error 89 | } 90 | 91 | func ListTracksIn(album *Album) ([]*Track, error) { 92 | var tracks []*Track 93 | 94 | result := Database. 95 | Preload("Album.Artist").Preload("Tags.Tag"). 96 | Order("title collate nocase asc").Find(&tracks, "album_id", album.ID) 97 | 98 | return tracks, result.Error 99 | } 100 | 101 | func ListTracksWith(tag *Tag) ([]*Track, error) { 102 | var tracks []*Track 103 | 104 | var trackTags []*TrackTag 105 | result := Database.Preload("Track.Album.Artist").Preload("Tag"). 106 | Find(&trackTags, "tag_id = ?", tag.ID) 107 | 108 | if result.Error == nil { 109 | for _, trackTags := range trackTags { 110 | tracks = append(tracks, &trackTags.Track) 111 | } 112 | } 113 | 114 | return tracks, result.Error 115 | } 116 | 117 | func ListTags() ([]*Tag, error) { 118 | var tags []*Tag 119 | 120 | result := Database.Order("name collate nocase asc").Find(&tags) 121 | 122 | return tags, result.Error 123 | } 124 | 125 | func StoreTrack(videoInfo *youtube.VideoInfo, trackInfo *providers.TrackInfo, downloaded bool) (*Track, error) { 126 | var err error 127 | // artist 128 | artist := Artist{ 129 | MBID: trackInfo.Artist.MBID, 130 | Name: trackInfo.Artist.Name, 131 | } 132 | err = Database.Where(Artist{MBID: trackInfo.Artist.MBID}). 133 | FirstOrCreate(&artist).Error 134 | if err != nil { 135 | return nil, err 136 | } 137 | 138 | // album 139 | album := Album{ 140 | MBID: trackInfo.Album.MBID, 141 | Title: trackInfo.Album.Title, 142 | Artist: artist, 143 | } 144 | err = Database.Where(Album{MBID: trackInfo.Album.MBID}). 145 | FirstOrCreate(&album).Error 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | // track 151 | track := Track{ 152 | MBID: trackInfo.MBID, 153 | Downloaded: downloaded, 154 | YoutubeID: videoInfo.ID, 155 | Album: album, 156 | Title: trackInfo.Title, 157 | PlayCount: 1, 158 | Length: int(videoInfo.Duration), 159 | YoutubeTitle: videoInfo.Title, 160 | } 161 | err = Database.Where(Track{YoutubeID: videoInfo.ID}). 162 | FirstOrCreate(&track).Error 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | // tags 168 | for _, tagName := range trackInfo.Tags { 169 | tag := Tag{ 170 | Name: tagName, 171 | } 172 | err = Database.Where(Tag{Name: tagName}). 173 | FirstOrCreate(&tag).Error 174 | if err != nil { 175 | return nil, err 176 | } 177 | trackTag := TrackTag{ 178 | Track: track, 179 | Tag: tag, 180 | } 181 | err = Database.Create(&trackTag).Error 182 | if err != nil { 183 | return nil, err 184 | } 185 | } 186 | 187 | return &track, nil 188 | } 189 | 190 | func LogStartup(version string) (previousVersion string, err error) { 191 | var startupLog NeptuneVersion 192 | res := Database.Last(&startupLog) 193 | if res.Error != nil && !errors.Is(gorm.ErrRecordNotFound, res.Error) { 194 | return "", res.Error 195 | } 196 | previousVersion = startupLog.Version 197 | 198 | if version != previousVersion { 199 | newStartupLog := NeptuneVersion{Version: version} 200 | err = Database.Create(&newStartupLog).Error 201 | } 202 | return 203 | } 204 | -------------------------------------------------------------------------------- /db/models.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "path" 5 | 6 | "github.com/Pauloo27/neptune/utils" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | type NeptuneVersion struct { 11 | gorm.Model 12 | Version string 13 | } 14 | 15 | type Artist struct { 16 | gorm.Model 17 | MBID string `gorm:"unique"` 18 | Name string 19 | } 20 | 21 | type Album struct { 22 | gorm.Model 23 | MBID string `gorm:"unique"` 24 | Title string 25 | ArtistID uint 26 | Artist Artist 27 | } 28 | 29 | type Tag struct { 30 | gorm.Model 31 | Name string `gorm:"unique"` 32 | } 33 | 34 | type TrackTag struct { 35 | gorm.Model 36 | TagID uint 37 | Tag Tag 38 | TrackID uint 39 | Track Track 40 | } 41 | 42 | type Track struct { 43 | gorm.Model 44 | MBID string 45 | Downloaded bool 46 | YoutubeID string `gorm:"unique"` 47 | AlbumID uint 48 | Album Album 49 | Title string 50 | Length int 51 | PlayCount int 52 | YoutubeTitle string 53 | Tags []TrackTag 54 | } 55 | 56 | func (t *Track) GetLocalPath() string { 57 | return path.Join(DataFolder, "albums", utils.Fmt("%d", t.Album.ID), t.YoutubeID+".m4a") 58 | } 59 | 60 | func (t *Track) GetYouTubeURL() string { 61 | return utils.Fmt("https://www.youtube.com/watch?v=%s", t.YoutubeID) 62 | } 63 | 64 | func (t *Track) GetPath() string { 65 | if t.Downloaded { 66 | return t.GetLocalPath() 67 | } 68 | return t.GetYouTubeURL() 69 | } 70 | 71 | func (a *Album) GetAlbumArtPath() string { 72 | return path.Join(a.GetAlbumPath(), ".folder.png") 73 | } 74 | 75 | func (a *Album) GetAlbumPath() string { 76 | return path.Join(DataFolder, "albums", utils.Fmt("%d", a.ID)) 77 | } 78 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Pauloo27/neptune 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/buger/jsonparser v1.1.1 7 | github.com/getlantern/systray v1.1.0 8 | github.com/gotk3/gotk3 v0.6.1 9 | gorm.io/driver/sqlite v1.1.4 10 | gorm.io/gorm v1.20.12 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 2 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= 5 | github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= 6 | github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= 7 | github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= 8 | github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk= 9 | github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= 10 | github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= 11 | github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= 12 | github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= 13 | github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= 14 | github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= 15 | github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= 16 | github.com/getlantern/systray v1.1.0 h1:U0wCEqseLi2ok1fE6b88gJklzriavPJixZysZPkZd/Y= 17 | github.com/getlantern/systray v1.1.0/go.mod h1:AecygODWIsBquJCJFop8MEQcJbWFfw/1yWbVabNgpCM= 18 | github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= 19 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 20 | github.com/gotk3/gotk3 v0.5.3-0.20210223154815-289cfb6dbf32 h1:wE6C/HgLUBHi8YhHlCEulrmQMntVl4PFdh3kA0sWyAY= 21 | github.com/gotk3/gotk3 v0.5.3-0.20210223154815-289cfb6dbf32/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= 22 | github.com/gotk3/gotk3 v0.6.1 h1:GJ400a0ecEEWrzjBvzBzH+pB/esEMIGdB9zPSmBdoeo= 23 | github.com/gotk3/gotk3 v0.6.1/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= 24 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 25 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 26 | github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E= 27 | github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 28 | github.com/mattn/go-sqlite3 v1.14.5 h1:1IdxlwTNazvbKJQSxoJ5/9ECbEeaTTyeU7sEAZ5KKTQ= 29 | github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= 30 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= 31 | github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= 32 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 33 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 34 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 35 | golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9 h1:YTzHMGlqJu67/uEo1lBv0n3wBXhXNeUbB1XfN2vmTm0= 36 | golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 37 | gorm.io/driver/sqlite v1.1.4 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM= 38 | gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw= 39 | gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 40 | gorm.io/gorm v1.20.12 h1:ebZ5KrSHzet+sqOCVdH9mTjW91L298nX3v5lVxAzSUY= 41 | gorm.io/gorm v1.20.12/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 42 | -------------------------------------------------------------------------------- /gui/app/explorer/library/album.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/db" 5 | "github.com/Pauloo27/neptune/utils" 6 | "github.com/gotk3/gotk3/gdk" 7 | "github.com/gotk3/gotk3/glib" 8 | "github.com/gotk3/gotk3/gtk" 9 | ) 10 | 11 | func createAlbumPage(album *db.Album) *LibraryPage { 12 | show := func() *gtk.Grid { 13 | container, err := gtk.GridNew() 14 | utils.HandleError(err, "Cannot create grid") 15 | 16 | go func() { 17 | tracks, err := db.ListTracksIn(album) 18 | utils.HandleError(err, "Cannot list tracks in album "+album.MBID) 19 | 20 | glib.IdleAdd(func() { 21 | albumArt, err := gtk.ImageNew() 22 | utils.HandleError(err, "Cannot create image") 23 | 24 | imagePath := album.GetAlbumArtPath() 25 | imagePix, err := gdk.PixbufNewFromFileAtScale(imagePath, 150, 150, true) 26 | utils.HandleError(err, "Cannot load image from file") 27 | albumArt.SetFromPixbuf(imagePix) 28 | albumArt.SetHAlign(gtk.ALIGN_CENTER) 29 | albumArt.SetMarginBottom(5) 30 | container.SetHAlign(gtk.ALIGN_CENTER) 31 | 32 | container.Attach(albumArt, 0, 0, 10, 1) 33 | 34 | container.Attach(createPlayAll("Play all", tracks), 0, 1, 1, 1) 35 | for i, track := range tracks { 36 | container.Attach(displayTrack(track, false), 0, i+2, 10, 1) 37 | } 38 | container.ShowAll() 39 | }) 40 | }() 41 | 42 | return container 43 | } 44 | 45 | return &LibraryPage{"Album: " + album.Title, show} 46 | } 47 | -------------------------------------------------------------------------------- /gui/app/explorer/library/albums.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/db" 5 | "github.com/Pauloo27/neptune/utils" 6 | "github.com/gotk3/gotk3/glib" 7 | "github.com/gotk3/gotk3/gtk" 8 | ) 9 | 10 | var albumsPage = &LibraryPage{"Albums", showAlbums} 11 | 12 | func displayAlbum(album *db.Album, showArtistName bool) *gtk.Button { 13 | var displayTitle string 14 | if showArtistName { 15 | displayTitle = utils.Fmt("%s by %s", utils.EnforceSize(album.Title, 40), utils.EnforceSize(album.Artist.Name, 30)) 16 | } else { 17 | displayTitle = album.Title 18 | } 19 | btn, err := gtk.ButtonNewWithLabel(displayTitle) 20 | utils.HandleError(err, "Cannot create button") 21 | 22 | btn.Connect("clicked", func() { 23 | displayPage(createAlbumPage(album)) 24 | }) 25 | 26 | return btn 27 | } 28 | func showAlbums() *gtk.Grid { 29 | container, err := gtk.GridNew() 30 | utils.HandleError(err, "Cannot create grid") 31 | 32 | container.SetRowSpacing(5) 33 | container.SetColumnHomogeneous(true) 34 | container.SetMarginStart(5) 35 | container.SetMarginEnd(5) 36 | 37 | go func() { 38 | albums, err := db.ListAlbums() 39 | utils.HandleError(err, "Cannot list albums") 40 | 41 | glib.IdleAdd(func() { 42 | for i, album := range albums { 43 | container.Attach(displayAlbum(album, true), 0, i, 1, 1) 44 | } 45 | container.ShowAll() 46 | }) 47 | }() 48 | 49 | return container 50 | } 51 | -------------------------------------------------------------------------------- /gui/app/explorer/library/artist.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/db" 5 | "github.com/Pauloo27/neptune/utils" 6 | "github.com/gotk3/gotk3/glib" 7 | "github.com/gotk3/gotk3/gtk" 8 | ) 9 | 10 | func createArtistPage(artist *db.Artist) *LibraryPage { 11 | show := func() *gtk.Grid { 12 | container, err := gtk.GridNew() 13 | utils.HandleError(err, "Cannot create box") 14 | 15 | container.SetRowSpacing(5) 16 | container.SetColumnHomogeneous(true) 17 | container.SetMarginStart(5) 18 | container.SetMarginEnd(5) 19 | 20 | go func() { 21 | albums, err := db.ListAlbumsBy(artist) 22 | utils.HandleError(err, "Cannot list albums by artist "+artist.MBID) 23 | 24 | tracks, err := db.ListTracksBy(artist) 25 | utils.HandleError(err, "Cannot list tracks by artist "+artist.MBID) 26 | 27 | glib.IdleAdd(func() { 28 | albumsLabel, err := gtk.LabelNew("Albums:") 29 | utils.HandleError(err, "Cannot create label") 30 | container.Attach(albumsLabel, 0, 0, 1, 1) 31 | 32 | i := 1 33 | for _, album := range albums { 34 | container.Attach(displayAlbum(album, false), 0, i, 1, 1) 35 | i++ 36 | } 37 | 38 | tracksLabel, err := gtk.LabelNew("Tracks:") 39 | utils.HandleError(err, "Cannot create label") 40 | 41 | container.Attach(tracksLabel, 0, i, 1, 1) 42 | i++ 43 | 44 | container.Attach(createPlayAll("Play all", tracks), 0, i, 1, 1) 45 | i++ 46 | 47 | for _, track := range tracks { 48 | container.Attach(displayTrack(track, false), 0, i, 1, 1) 49 | i++ 50 | } 51 | container.ShowAll() 52 | }) 53 | }() 54 | 55 | return container 56 | } 57 | return &LibraryPage{"Artist: " + artist.Name, show} 58 | } 59 | -------------------------------------------------------------------------------- /gui/app/explorer/library/artists.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/db" 5 | "github.com/Pauloo27/neptune/utils" 6 | "github.com/gotk3/gotk3/glib" 7 | "github.com/gotk3/gotk3/gtk" 8 | ) 9 | 10 | var ( 11 | artistsPage = &LibraryPage{"Artists", showArtists} 12 | ) 13 | 14 | func displayArtist(artist *db.Artist) *gtk.Button { 15 | btn, err := gtk.ButtonNewWithLabel(artist.Name) 16 | utils.HandleError(err, "Cannot create button") 17 | 18 | btn.Connect("clicked", func() { 19 | displayPage(createArtistPage(artist)) 20 | }) 21 | 22 | return btn 23 | } 24 | 25 | func showArtists() *gtk.Grid { 26 | container, err := gtk.GridNew() 27 | utils.HandleError(err, "Cannot create grid") 28 | 29 | container.SetRowSpacing(5) 30 | container.SetColumnHomogeneous(true) 31 | container.SetMarginStart(5) 32 | container.SetMarginEnd(5) 33 | 34 | go func() { 35 | artists, err := db.ListArtists() 36 | utils.HandleError(err, "Cannot list artist") 37 | 38 | glib.IdleAdd(func() { 39 | for i, artist := range artists { 40 | container.Attach(displayArtist(artist), 0, i, 1, 1) 41 | } 42 | container.ShowAll() 43 | }) 44 | }() 45 | 46 | return container 47 | } 48 | -------------------------------------------------------------------------------- /gui/app/explorer/library/main.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/utils" 5 | "github.com/gotk3/gotk3/gtk" 6 | ) 7 | 8 | var ( 9 | titleHeader *gtk.Label 10 | goBackBtn *gtk.Button 11 | contentContainer *gtk.Grid 12 | libraryContainer *gtk.Box 13 | scroller *gtk.ScrolledWindow 14 | 15 | homePage = &LibraryPage{"Home", showHome} 16 | ) 17 | 18 | type ShowFunction func() *gtk.Grid 19 | 20 | type LibraryPage struct { 21 | PageTitle string 22 | ShowPage ShowFunction 23 | } 24 | 25 | func displayPage(page *LibraryPage) { 26 | titleHeader.SetText(page.PageTitle) 27 | 28 | if scroller != nil { 29 | scroller.Destroy() 30 | } 31 | 32 | if contentContainer != nil { 33 | contentContainer.Destroy() 34 | } 35 | 36 | var err error 37 | 38 | scroller, err = gtk.ScrolledWindowNew(nil, nil) 39 | utils.HandleError(err, "Cannot create scrolled window") 40 | 41 | contentContainer = page.ShowPage() 42 | scroller.Add(contentContainer) 43 | 44 | libraryContainer.PackStart(scroller, true, true, 0) 45 | 46 | libraryContainer.ShowAll() 47 | } 48 | 49 | func showHome() *gtk.Grid { 50 | container, err := gtk.GridNew() 51 | utils.HandleError(err, "Cannot create grid") 52 | 53 | container.SetRowSpacing(5) 54 | container.SetColumnHomogeneous(true) 55 | container.SetMarginStart(5) 56 | container.SetMarginEnd(5) 57 | 58 | i := 0 59 | addBtn := func(page *LibraryPage) { 60 | button, err := gtk.ButtonNewWithLabel(page.PageTitle) 61 | utils.HandleError(err, "Cannot create button") 62 | 63 | button.Connect("clicked", func() { 64 | displayPage(page) 65 | }) 66 | 67 | container.Attach(button, 0, i, 1, 1) 68 | i++ 69 | } 70 | 71 | addBtn(tracksPage) 72 | addBtn(albumsPage) 73 | addBtn(artistsPage) 74 | addBtn(tagsPage) 75 | 76 | return container 77 | } 78 | 79 | func CreateLibraryPage() *gtk.Box { 80 | var err error 81 | libraryContainer, err = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 5) 82 | utils.HandleError(err, "Cannot create box") 83 | 84 | titleHeader, err = gtk.LabelNew("") 85 | utils.HandleError(err, "Cannot create label") 86 | 87 | titleHeader.SetHAlign(gtk.ALIGN_CENTER) 88 | 89 | goBackBtn, err = gtk.ButtonNewWithLabel("Home") 90 | utils.HandleError(err, "Cannot create button") 91 | 92 | goBackBtn.Connect("clicked", func() { 93 | displayPage(homePage) 94 | }) 95 | 96 | libraryContainer.PackStart(goBackBtn, false, false, 0) 97 | libraryContainer.PackStart(titleHeader, false, false, 0) 98 | 99 | displayPage(homePage) 100 | 101 | return libraryContainer 102 | } 103 | -------------------------------------------------------------------------------- /gui/app/explorer/library/play_all.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/db" 5 | "github.com/Pauloo27/neptune/player" 6 | "github.com/Pauloo27/neptune/utils" 7 | "github.com/gotk3/gotk3/gtk" 8 | ) 9 | 10 | func createPlayAll(labelText string, tracks []*db.Track) *gtk.Box { 11 | return createPlayAllContainer(labelText, func() { player.PlayTracks(tracks) }) 12 | } 13 | 14 | func createFuturePlayAll(labelText string, loader func() []*db.Track) *gtk.Box { 15 | return createPlayAllContainer(labelText, func() { player.PlayTracks(loader()) }) 16 | } 17 | 18 | func createPlayAllContainer(labelText string, onClick func()) *gtk.Box { 19 | hbox, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 1) 20 | utils.HandleError(err, "Cannot create box") 21 | 22 | hbox.SetMarginStart(5) 23 | hbox.SetMarginEnd(5) 24 | 25 | playButton, err := gtk.ButtonNewFromIconName("media-playback-start", gtk.ICON_SIZE_BUTTON) 26 | utils.HandleError(err, "Cannot create label") 27 | 28 | playButton.Connect("clicked", onClick) 29 | 30 | label, err := gtk.LabelNew(labelText) 31 | utils.HandleError(err, "Cannot create label") 32 | 33 | hbox.PackStart(playButton, false, false, 1) 34 | hbox.PackStart(label, false, false, 1) 35 | return hbox 36 | } 37 | -------------------------------------------------------------------------------- /gui/app/explorer/library/tag.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/db" 5 | "github.com/Pauloo27/neptune/utils" 6 | "github.com/gotk3/gotk3/glib" 7 | "github.com/gotk3/gotk3/gtk" 8 | ) 9 | 10 | func createTagPage(tag *db.Tag) *LibraryPage { 11 | show := func() *gtk.Grid { 12 | container, err := gtk.GridNew() 13 | utils.HandleError(err, "Cannot create box") 14 | 15 | container.SetRowSpacing(5) 16 | container.SetColumnHomogeneous(true) 17 | container.SetMarginStart(5) 18 | container.SetMarginEnd(5) 19 | 20 | go func() { 21 | tracks, err := db.ListTracksWith(tag) 22 | utils.HandleError(err, "Cannot list tracks with tag "+tag.Name) 23 | 24 | glib.IdleAdd(func() { 25 | container.Attach(createPlayAll("Play all", tracks), 0, 0, 1, 1) 26 | for i, track := range tracks { 27 | container.Attach(displayTrack(track, true), 0, i+1, 1, 1) 28 | } 29 | container.ShowAll() 30 | }) 31 | }() 32 | 33 | return container 34 | } 35 | 36 | return &LibraryPage{"Tag: " + tag.Name, show} 37 | } 38 | -------------------------------------------------------------------------------- /gui/app/explorer/library/tags.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/db" 5 | "github.com/Pauloo27/neptune/utils" 6 | "github.com/gotk3/gotk3/glib" 7 | "github.com/gotk3/gotk3/gtk" 8 | ) 9 | 10 | var tagsPage = &LibraryPage{"Tags", showTags} 11 | 12 | func displayTag(tag *db.Tag) *gtk.Button { 13 | btn, err := gtk.ButtonNewWithLabel(tag.Name) 14 | utils.HandleError(err, "Cannot create button") 15 | 16 | btn.Connect("clicked", func() { 17 | displayPage(createTagPage(tag)) 18 | }) 19 | 20 | return btn 21 | } 22 | 23 | func showTags() *gtk.Grid { 24 | container, err := gtk.GridNew() 25 | utils.HandleError(err, "Cannot create grid") 26 | 27 | container.SetRowSpacing(5) 28 | container.SetColumnHomogeneous(true) 29 | container.SetMarginStart(5) 30 | container.SetMarginEnd(5) 31 | 32 | go func() { 33 | tags, err := db.ListTags() 34 | utils.HandleError(err, "Cannot list tags") 35 | 36 | glib.IdleAdd(func() { 37 | for i, tag := range tags { 38 | container.Attach(displayTag(tag), 0, i, 1, 1) 39 | } 40 | container.ShowAll() 41 | }) 42 | }() 43 | 44 | return container 45 | } 46 | -------------------------------------------------------------------------------- /gui/app/explorer/library/tracks.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/db" 5 | "github.com/Pauloo27/neptune/player" 6 | "github.com/Pauloo27/neptune/utils" 7 | "github.com/gotk3/gotk3/glib" 8 | "github.com/gotk3/gotk3/gtk" 9 | ) 10 | 11 | var ( 12 | tracksPage = &LibraryPage{"Tracks", showTracks} 13 | ) 14 | 15 | func displayTrack(track *db.Track, showArtist bool) *gtk.Box { 16 | hbox, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 1) 17 | utils.HandleError(err, "Cannot create box") 18 | 19 | hbox.SetMarginStart(5) 20 | hbox.SetMarginEnd(5) 21 | 22 | playButton, err := gtk.ButtonNewFromIconName("media-playback-start", gtk.ICON_SIZE_BUTTON) 23 | utils.HandleError(err, "Cannot create button") 24 | 25 | playButton.Connect("clicked", func() { 26 | go player.PlayTrack(track) 27 | }) 28 | 29 | addToQueueButton, err := gtk.ButtonNewFromIconName("list-add", gtk.ICON_SIZE_BUTTON) 30 | utils.HandleError(err, "Cannot create button") 31 | 32 | addToQueueButton.Connect("clicked", func() { 33 | go player.AddTrackToQueue(track) 34 | }) 35 | 36 | var fullTitle string 37 | if showArtist { 38 | fullTitle = utils.Fmt("%s from %s", utils.EnforceSize(track.Title, 50), utils.EnforceSize(track.Album.Artist.Name, 20)) 39 | } else { 40 | fullTitle = track.Title 41 | } 42 | 43 | songLabel, err := gtk.LabelNew(fullTitle) 44 | utils.HandleError(err, "Cannot create label") 45 | 46 | hbox.PackStart(playButton, false, false, 1) 47 | hbox.PackStart(addToQueueButton, false, false, 1) 48 | hbox.PackStart(songLabel, false, false, 1) 49 | return hbox 50 | } 51 | 52 | func appendTracks(container *gtk.Grid, tracks []*db.Track, offset int) int { 53 | for i, track := range tracks { 54 | container.Attach(displayTrack(track, true), 0, i+offset, 10, 1) 55 | } 56 | 57 | container.ShowAll() 58 | return len(tracks) 59 | } 60 | 61 | func showTracks() *gtk.Grid { 62 | container, err := gtk.GridNew() 63 | utils.HandleError(err, "Cannot create grid") 64 | 65 | offset := 0 66 | page := 0 67 | 68 | tracksContainer, err := gtk.GridNew() 69 | utils.HandleError(err, "Cannot create grid") 70 | 71 | tracksContainer.SetRowSpacing(1) 72 | container.SetRowSpacing(1) 73 | 74 | container.SetColumnHomogeneous(true) 75 | 76 | loadPage := func(page int) { 77 | tracks, err := db.ListTracks(page) 78 | utils.HandleError(err, "Cannot list tracks") 79 | 80 | glib.IdleAdd(func() { 81 | offset += appendTracks(tracksContainer, tracks, offset) 82 | }) 83 | } 84 | 85 | loadMoreButton, err := gtk.ButtonNewWithLabel("Load more") 86 | utils.HandleError(err, "Cannot create button") 87 | 88 | loadMoreButton.Connect("clicked", func() { 89 | page++ 90 | go loadPage(page) 91 | }) 92 | 93 | container.Attach(createFuturePlayAll("Play all tracks", func() []*db.Track { 94 | tracks, err := db.ListAllTracks() 95 | utils.HandleError(err, "Cannot list all tracks") 96 | 97 | return tracks 98 | }), 0, 0, 1, 1) 99 | container.Attach(tracksContainer, 0, 1, 1, 1) 100 | container.Attach(loadMoreButton, 0, 2, 1, 1) 101 | 102 | go loadPage(page) 103 | 104 | return container 105 | } 106 | -------------------------------------------------------------------------------- /gui/app/explorer/main.go: -------------------------------------------------------------------------------- 1 | package explorer 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/gui/app/explorer/library" 5 | "github.com/Pauloo27/neptune/utils" 6 | "github.com/gotk3/gotk3/gtk" 7 | ) 8 | 9 | var explorerContainer *gtk.Notebook 10 | 11 | func CreateExplorer() *gtk.Notebook { 12 | var err error 13 | explorerContainer, err = gtk.NotebookNew() 14 | utils.HandleError(err, "Cannot create notebook") 15 | 16 | libraryLabel, err := gtk.LabelNew("Library") 17 | explorerContainer.AppendPage(library.CreateLibraryPage(), libraryLabel) 18 | 19 | searchLabel, err := gtk.LabelNew("Search") 20 | explorerContainer.AppendPage(createSearchPage(), searchLabel) 21 | 22 | queueLabel, err := gtk.LabelNew("Queue") 23 | explorerContainer.AppendPage(createQueuePage(), queueLabel) 24 | 25 | return explorerContainer 26 | } 27 | -------------------------------------------------------------------------------- /gui/app/explorer/queue.go: -------------------------------------------------------------------------------- 1 | package explorer 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/db" 5 | "github.com/Pauloo27/neptune/hook" 6 | "github.com/Pauloo27/neptune/player" 7 | "github.com/Pauloo27/neptune/utils" 8 | "github.com/gotk3/gotk3/glib" 9 | "github.com/gotk3/gotk3/gtk" 10 | ) 11 | 12 | func createQueueEntry(track *db.Track, queueIndex int) *gtk.Box { 13 | container, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 1) 14 | utils.HandleError(err, "Cannot create box") 15 | 16 | trackLabel, err := gtk.LabelNew(utils.Fmt("%s - %s", utils.EnforceSize(track.Album.Artist.Name, 15), utils.EnforceSize(track.Title, 50))) 17 | utils.HandleError(err, "Cannot create label") 18 | 19 | moveUpButton, err := gtk.ButtonNewFromIconName("go-up", gtk.ICON_SIZE_BUTTON) 20 | utils.HandleError(err, "Cannot create button") 21 | 22 | moveUpButton.Connect("clicked", func() { 23 | player.MoveUpInQueue(queueIndex) 24 | }) 25 | 26 | moveDownButton, err := gtk.ButtonNewFromIconName("go-down", gtk.ICON_SIZE_BUTTON) 27 | utils.HandleError(err, "Cannot create button") 28 | 29 | moveDownButton.Connect("clicked", func() { 30 | player.MoveDownInQueue(queueIndex) 31 | }) 32 | 33 | removeButton, err := gtk.ButtonNewFromIconName("delete", gtk.ICON_SIZE_BUTTON) 34 | utils.HandleError(err, "Cannot create button") 35 | 36 | removeButton.Connect("clicked", func() { 37 | player.RemoveFromQueue(queueIndex) 38 | }) 39 | 40 | container.PackStart(trackLabel, false, false, 1) 41 | container.PackEnd(removeButton, false, false, 1) 42 | container.PackEnd(moveDownButton, false, false, 1) 43 | container.PackEnd(moveUpButton, false, false, 1) 44 | 45 | return container 46 | } 47 | 48 | func createQueuePage() *gtk.ScrolledWindow { 49 | container, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 1) 50 | utils.HandleError(err, "Cannot create box") 51 | 52 | headerContainer, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 1) 53 | utils.HandleError(err, "Cannot create box") 54 | 55 | headerContainer.SetHAlign(gtk.ALIGN_CENTER) 56 | 57 | shuffleButton, err := gtk.ButtonNewFromIconName("shuffle", gtk.ICON_SIZE_BUTTON) 58 | utils.HandleError(err, "Cannot create button") 59 | 60 | shuffleButton.Connect("clicked", func() { 61 | player.Shuffle() 62 | }) 63 | 64 | clearQueueButton, err := gtk.ButtonNewFromIconName("delete", gtk.ICON_SIZE_BUTTON) 65 | utils.HandleError(err, "Cannot create button") 66 | 67 | clearQueueButton.Connect("clicked", func() { 68 | player.ClearQueue() 69 | }) 70 | 71 | loopButton, err := gtk.ButtonNew() 72 | utils.HandleError(err, "Cannot create button") 73 | 74 | loopNoneIcon, err := gtk.ImageNewFromIconName("format-justify-fill", gtk.ICON_SIZE_BUTTON) 75 | utils.HandleError(err, "Cannot create image") 76 | 77 | loopTrackIcon, err := gtk.ImageNewFromIconName("media-playlist-repeat-song", gtk.ICON_SIZE_BUTTON) 78 | utils.HandleError(err, "Cannot create image") 79 | 80 | loopQueueIcon, err := gtk.ImageNewFromIconName("media-playlist-repeat", gtk.ICON_SIZE_BUTTON) 81 | utils.HandleError(err, "Cannot create image") 82 | 83 | loopButton.SetImage(loopNoneIcon) 84 | 85 | loopButton.Connect("clicked", func() { 86 | player.NextLoopStatus() 87 | }) 88 | 89 | hook.RegisterHook(hook.HOOK_LOOP_STATUS_CHANGED, func(params ...interface{}) { 90 | loopStatus := player.GetLoopStatus() 91 | glib.IdleAdd(func() { 92 | var icon *gtk.Image 93 | switch loopStatus { 94 | case player.LOOP_NONE: 95 | icon = loopNoneIcon 96 | case player.LOOP_TRACK: 97 | icon = loopTrackIcon 98 | case player.LOOP_QUEUE: 99 | icon = loopQueueIcon 100 | } 101 | loopButton.SetImage(icon) 102 | }) 103 | }) 104 | 105 | headerContainer.PackStart(shuffleButton, false, false, 1) 106 | headerContainer.PackStart(clearQueueButton, false, false, 1) 107 | headerContainer.PackStart(loopButton, false, false, 1) 108 | 109 | queueContainer, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 1) 110 | utils.HandleError(err, "Cannot create box") 111 | 112 | container.PackStart(headerContainer, false, false, 1) 113 | container.PackStart(queueContainer, false, false, 1) 114 | 115 | hook.RegisterHook( 116 | hook.HOOK_QUEUE_UPDATE_FINISHED, 117 | func(params ...interface{}) { 118 | glib.IdleAdd(func() { 119 | queueContainer.GetChildren().Foreach(func(item interface{}) { 120 | item.(*gtk.Widget).Destroy() 121 | }) 122 | for i := 0; i < len(player.State.Queue); i++ { 123 | track := player.GetTrackAt(i) 124 | 125 | queueContainer.PackStart(createQueueEntry(track, i), false, false, 1) 126 | } 127 | queueContainer.ShowAll() 128 | }) 129 | }, 130 | ) 131 | 132 | scrolledContainer, err := gtk.ScrolledWindowNew(nil, nil) 133 | utils.HandleError(err, "Cannot create scrolled window") 134 | 135 | scrolledContainer.Add(container) 136 | 137 | return scrolledContainer 138 | } 139 | -------------------------------------------------------------------------------- /gui/app/explorer/search.go: -------------------------------------------------------------------------------- 1 | package explorer 2 | 3 | import ( 4 | "runtime" 5 | 6 | "github.com/Pauloo27/neptune/content_manager" 7 | "github.com/Pauloo27/neptune/providers/youtube" 8 | "github.com/Pauloo27/neptune/utils" 9 | "github.com/gotk3/gotk3/glib" 10 | "github.com/gotk3/gotk3/gtk" 11 | ) 12 | 13 | var searchResultsContainer *gtk.Box 14 | var searchStatusLabel *gtk.Label 15 | var searching = false 16 | 17 | func doSearch(searchTerm string) { 18 | if searchTerm == "" || searching { 19 | return 20 | } 21 | searching = true 22 | explorerContainer.SetCurrentPage(1) 23 | 24 | children := searchResultsContainer.GetChildren() 25 | 26 | id := 0 27 | children.Foreach(func(item interface{}) { 28 | wid := item.(*gtk.Widget) 29 | // ignore the label 30 | if id != 0 { 31 | wid.Destroy() 32 | } 33 | id++ 34 | }) 35 | searchStatusLabel.SetText("Searching for " + searchTerm) 36 | 37 | go func() { 38 | results, err := youtube.SearchFor(searchTerm, 10) 39 | if err != nil { 40 | glib.IdleAdd(func() { 41 | searchStatusLabel.SetText("Something went wrong") 42 | searching = false 43 | }) 44 | } else { 45 | glib.IdleAdd(func() { 46 | searchStatusLabel.SetText("Results:") 47 | for _, result := range results { 48 | resultContainer, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 1) 49 | utils.HandleError(err, "Cannot create label") 50 | 51 | playButton, err := gtk.ButtonNewFromIconName("media-playback-start", gtk.ICON_SIZE_BUTTON) 52 | utils.HandleError(err, "Cannot create label") 53 | 54 | // var result, at the end of the for, will be the last array element 55 | currentResult := result 56 | playButton.Connect("clicked", func() { 57 | if runtime.GOOS == "windows" { 58 | content_manager.Download(currentResult) 59 | } else { 60 | content_manager.Download(currentResult) 61 | //content_manager.Stream(currentResult) 62 | } 63 | }) 64 | 65 | label, err := gtk.LabelNew(utils.Fmt("%s - %s | %s", 66 | utils.EnforceSize(result.Title, 60), 67 | utils.EnforceSize(result.Uploader, 20), 68 | result.DisplayDuration, 69 | )) 70 | utils.HandleError(err, "Cannot create label") 71 | 72 | resultContainer.PackStart(playButton, false, false, 1) 73 | resultContainer.PackStart(label, false, false, 1) 74 | 75 | searchResultsContainer.PackStart(resultContainer, false, false, 1) 76 | } 77 | searchResultsContainer.ShowAll() 78 | searching = false 79 | }) 80 | } 81 | }() 82 | } 83 | 84 | func CreateSearchHeader() *gtk.HeaderBar { 85 | searchBarContainer, err := gtk.HeaderBarNew() 86 | utils.HandleError(err, "Cannot create header bar") 87 | 88 | searchBarContainer.SetShowCloseButton(false) 89 | 90 | searchInput, err := gtk.EntryNew() 91 | utils.HandleError(err, "Cannot create entry") 92 | 93 | searchInput.SetPlaceholderText("Search YouTube") 94 | searchInput.SetHExpand(true) 95 | 96 | handleSearch := func() { 97 | text, err := searchInput.GetText() 98 | utils.HandleError(err, "Cannot get entry text") 99 | 100 | doSearch(text) 101 | } 102 | 103 | searchButton, err := gtk.ButtonNewFromIconName("search", gtk.ICON_SIZE_BUTTON) 104 | utils.HandleError(err, "Cannot create button") 105 | 106 | searchInput.Connect("activate", handleSearch) 107 | searchButton.Connect("clicked", handleSearch) 108 | 109 | searchBarContainer.PackStart(searchInput) 110 | searchBarContainer.PackStart(searchButton) 111 | 112 | return searchBarContainer 113 | } 114 | 115 | func createSearchPage() *gtk.Box { 116 | var err error 117 | searchResultsContainer, err = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 1) 118 | utils.HandleError(err, "Cannot create box") 119 | 120 | searchStatusLabel, err = gtk.LabelNew("Nothing yet") 121 | utils.HandleError(err, "Cannot create label") 122 | 123 | searchResultsContainer.PackStart(searchStatusLabel, false, false, 1) 124 | 125 | return searchResultsContainer 126 | } 127 | -------------------------------------------------------------------------------- /gui/app/main.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/Pauloo27/neptune/gui/app/explorer" 7 | "github.com/Pauloo27/neptune/gui/app/player" 8 | "github.com/Pauloo27/neptune/hook" 9 | "github.com/Pauloo27/neptune/utils" 10 | "github.com/gotk3/gotk3/gtk" 11 | ) 12 | 13 | var appWin *gtk.Window 14 | 15 | func Start(onExit func()) { 16 | gtk.Init(nil) 17 | 18 | win, err := gtk.WindowNew(gtk.WINDOW_TOPLEVEL) 19 | utils.HandleError(err, "Cannot create window") 20 | 21 | win.SetTitle("Neptune") 22 | win.Connect("destroy", func() { 23 | onExit() 24 | gtk.MainQuit() 25 | os.Exit(0) 26 | }) 27 | 28 | appWin = win 29 | 30 | baseContainer, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) 31 | 32 | // main content container 33 | mainContainer, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) 34 | utils.HandleError(err, "Cannot create box") 35 | 36 | mainContainer.SetHomogeneous(true) 37 | mainContainer.PackStart(player.CreatePlayer(), false, true, 1) 38 | mainContainer.PackEnd(explorer.CreateExplorer(), false, true, 1) 39 | 40 | win.Add(baseContainer) 41 | 42 | baseContainer.PackStart(explorer.CreateSearchHeader(), false, false, 0) 43 | baseContainer.PackEnd(mainContainer, true, true, 0) 44 | 45 | win.SetDefaultSize(800, 600) 46 | 47 | win.ShowAll() 48 | 49 | hook.RegisterHook(hook.HOOK_REQUEST_EXIT, func(params ...interface{}) { 50 | onExit() 51 | gtk.MainQuit() 52 | os.Exit(0) 53 | }) 54 | 55 | hook.RegisterHook(hook.HOOK_REQUEST_SHOW_HIDE, func(params ...interface{}) { 56 | win.SetVisible(!win.GetVisible()) 57 | }) 58 | 59 | hook.CallHooks(hook.HOOK_GUI_STARTED) 60 | 61 | gtk.Main() 62 | } 63 | -------------------------------------------------------------------------------- /gui/app/player/bottom.go: -------------------------------------------------------------------------------- 1 | package player 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/Pauloo27/neptune/hook" 7 | "github.com/Pauloo27/neptune/player" 8 | "github.com/Pauloo27/neptune/utils" 9 | "github.com/gotk3/gotk3/glib" 10 | "github.com/gotk3/gotk3/gtk" 11 | ) 12 | 13 | var positionLabel *gtk.Label 14 | var progressBar *gtk.Scale 15 | var currentPosition float64 16 | 17 | func createProgressBar() *gtk.Scale { 18 | var err error 19 | 20 | progressBar, err = gtk.ScaleNewWithRange(gtk.ORIENTATION_HORIZONTAL, 0.0, 1.0, 0.01) 21 | utils.HandleError(err, "Cannot create scale") 22 | 23 | progressBar.SetDrawValue(false) 24 | progressBar.SetHExpand(true) 25 | progressBar.Connect("value-changed", func() { 26 | value := progressBar.GetValue() 27 | if value == currentPosition { 28 | return 29 | } 30 | player.SetPosition(value * player.State.Duration) 31 | }) 32 | 33 | return progressBar 34 | } 35 | 36 | func createVolumeController() *gtk.Box { 37 | volumeContainer, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) 38 | utils.HandleError(err, "Cannot create box") 39 | 40 | volumeIcon, err := gtk.ImageNewFromIconName("audio-volume-medium", gtk.ICON_SIZE_BUTTON) 41 | utils.HandleError(err, "Cannot create image") 42 | 43 | volumeController, err := gtk.ScaleNewWithRange(gtk.ORIENTATION_HORIZONTAL, 0.0, 100.0, 1.0) 44 | utils.HandleError(err, "Cannot create box") 45 | 46 | volumeController.SetDrawValue(false) 47 | volumeController.SetValue(player.State.Volume) 48 | 49 | hook.RegisterHook(hook.HOOK_VOLUME_CHANGED, func(params ...interface{}) { 50 | volume := params[0].(float64) 51 | glib.IdleAdd(func() { 52 | if volume != volumeController.GetValue() { 53 | volumeController.SetValue(volume) 54 | } 55 | }) 56 | }) 57 | 58 | volumeController.Connect("value-changed", func() { 59 | player.SetVolume(volumeController.GetValue()) 60 | }) 61 | 62 | volumeContainer.PackStart(volumeIcon, false, false, 0) 63 | volumeContainer.PackEnd(volumeController, true, true, 0) 64 | 65 | return volumeContainer 66 | } 67 | 68 | func createDurationLabel() *gtk.Label { 69 | durationLabel, err := gtk.LabelNew("--:--") 70 | utils.HandleError(err, "Cannot create label") 71 | 72 | durationLabel.SetHAlign(gtk.ALIGN_END) 73 | hook.RegisterHook(hook.HOOK_FILE_LOADED, func(params ...interface{}) { 74 | duration := params[1].(float64) 75 | glib.IdleAdd(func() { 76 | durationLabel.SetText(utils.FormatDuration(duration)) 77 | }) 78 | }) 79 | 80 | return durationLabel 81 | } 82 | 83 | func updatePosition(position float64) { 84 | positionLabel.SetText(utils.FormatDuration(position)) 85 | currentPosition = position / player.State.Duration 86 | progressBar.SetValue(currentPosition) 87 | } 88 | 89 | func progressUpdater() { 90 | for { 91 | position, err := player.GetPosition() 92 | if err == nil { 93 | glib.IdleAdd(func() { 94 | updatePosition(position) 95 | }) 96 | } 97 | time.Sleep(1 * time.Second) 98 | } 99 | } 100 | 101 | func createPositionLabel() *gtk.Label { 102 | var err error 103 | positionLabel, err = gtk.LabelNew("--:--") 104 | utils.HandleError(err, "Cannot create label") 105 | 106 | positionLabel.SetHAlign(gtk.ALIGN_START) 107 | 108 | go progressUpdater() 109 | 110 | return positionLabel 111 | } 112 | 113 | func createSongLabel() *gtk.Label { 114 | songLabel, err := gtk.LabelNew("--") 115 | utils.HandleError(err, "Cannot create label") 116 | 117 | songLabel.SetHAlign(gtk.ALIGN_CENTER) 118 | 119 | hook.RegisterHook(hook.HOOK_RESULT_FETCH_STARTED, func(params ...interface{}) { 120 | entry := player.State.Fetching 121 | glib.IdleAdd(func() { 122 | songLabel.SetText(utils.Fmt("Fetching %s...", entry.Title)) 123 | }) 124 | }) 125 | 126 | hook.RegisterHook(hook.HOOK_FILE_LOADED, func(params ...interface{}) { 127 | track := player.GetCurrentTrack() 128 | glib.IdleAdd(func() { 129 | songLabel.SetText(utils.Fmt("%s - %s", track.Album.Artist.Name, track.Title)) 130 | }) 131 | }) 132 | 133 | return songLabel 134 | } 135 | 136 | func createTimeStampContainer() *gtk.Box { 137 | timeStampContainer, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) 138 | utils.HandleError(err, "Cannot create box") 139 | 140 | timeStampContainer.PackStart(createPositionLabel(), false, false, 0) 141 | timeStampContainer.PackEnd(createDurationLabel(), false, false, 0) 142 | 143 | return timeStampContainer 144 | } 145 | 146 | func createButtonsContainer() *gtk.Box { 147 | buttonsContainer, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 3) 148 | utils.HandleError(err, "Cannot create box") 149 | 150 | pausedIcon, err := gtk.ImageNewFromIconName("media-playback-start", gtk.ICON_SIZE_BUTTON) 151 | playingIcon, err := gtk.ImageNewFromIconName("media-playback-pause", gtk.ICON_SIZE_BUTTON) 152 | 153 | pauseButton, err := gtk.ButtonNew() 154 | utils.HandleError(err, "Cannot create button") 155 | 156 | pauseButton.SetImage(playingIcon) 157 | 158 | hook.RegisterHook(hook.HOOK_PLAYBACK_PAUSED, func(params ...interface{}) { 159 | glib.IdleAdd(func() { 160 | pauseButton.SetImage(pausedIcon) 161 | }) 162 | }) 163 | hook.RegisterHook(hook.HOOK_PLAYBACK_RESUMED, func(params ...interface{}) { 164 | glib.IdleAdd(func() { 165 | pauseButton.SetImage(playingIcon) 166 | }) 167 | }) 168 | 169 | pauseButton.Connect("clicked", func() { 170 | player.PlayPause() 171 | }) 172 | 173 | prevButton, err := gtk.ButtonNewFromIconName("media-seek-backward", gtk.ICON_SIZE_BUTTON) 174 | utils.HandleError(err, "Cannot create button") 175 | 176 | prevButton.Connect("clicked", func() { 177 | player.PreviousTrack() 178 | }) 179 | 180 | nextButton, err := gtk.ButtonNewFromIconName("media-seek-forward", gtk.ICON_SIZE_BUTTON) 181 | utils.HandleError(err, "Cannot create button") 182 | 183 | nextButton.Connect("clicked", func() { 184 | player.NextTrack() 185 | }) 186 | 187 | buttonsContainer.SetHAlign(gtk.ALIGN_CENTER) 188 | 189 | buttonsContainer.PackStart(prevButton, false, true, 0) 190 | buttonsContainer.PackStart(pauseButton, false, true, 0) 191 | buttonsContainer.PackStart(nextButton, false, true, 0) 192 | 193 | return buttonsContainer 194 | } 195 | 196 | func createPlayerBottom() *gtk.Grid { 197 | bottomContainer, err := gtk.GridNew() 198 | utils.HandleError(err, "Cannot create grid") 199 | 200 | // row 0 201 | bottomContainer.Attach(createSongLabel(), 0, 0, 10, 1) 202 | // row 1 203 | bottomContainer.Attach(createProgressBar(), 0, 1, 10, 1) 204 | // row 2 205 | bottomContainer.Attach(createTimeStampContainer(), 0, 2, 10, 1) 206 | // row 3 207 | bottomContainer.Attach(createVolumeController(), 0, 3, 3, 1) 208 | bottomContainer.Attach(createButtonsContainer(), 3, 3, 4, 1) 209 | 210 | return bottomContainer 211 | } 212 | -------------------------------------------------------------------------------- /gui/app/player/main.go: -------------------------------------------------------------------------------- 1 | package player 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/utils" 5 | "github.com/gotk3/gotk3/gtk" 6 | ) 7 | 8 | func CreatePlayer() *gtk.Box { 9 | playerContainer, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) 10 | utils.HandleError(err, "Cannot create box") 11 | 12 | playerContainer.PackStart(createPlayerTop(), true, true, 0) 13 | playerContainer.PackEnd(createPlayerBottom(), false, false, 0) 14 | 15 | return playerContainer 16 | } 17 | -------------------------------------------------------------------------------- /gui/app/player/top.go: -------------------------------------------------------------------------------- 1 | package player 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/hook" 5 | "github.com/Pauloo27/neptune/player" 6 | "github.com/Pauloo27/neptune/utils" 7 | "github.com/gotk3/gotk3/gdk" 8 | "github.com/gotk3/gotk3/glib" 9 | "github.com/gotk3/gotk3/gtk" 10 | ) 11 | 12 | func createPlayerTop() *gtk.Box { 13 | topContainer, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) 14 | utils.HandleError(err, "Cannot create box") 15 | 16 | // album art 17 | albumArt, err := gtk.ImageNew() 18 | utils.HandleError(err, "Cannot create image") 19 | 20 | albumArt.SetVAlign(gtk.ALIGN_CENTER) 21 | 22 | hook.RegisterHook(hook.HOOK_FILE_LOADED, func(params ...interface{}) { 23 | imagePath := player.GetCurrentTrack().Album.GetAlbumArtPath() 24 | glib.IdleAdd(func() { 25 | imagePix, err := gdk.PixbufNewFromFileAtScale(imagePath, 300, 300, true) 26 | utils.HandleError(err, "Cannot load image from file") 27 | albumArt.SetFromPixbuf(imagePix) 28 | }) 29 | }) 30 | 31 | topContainer.PackStart(albumArt, true, true, 1) 32 | 33 | return topContainer 34 | } 35 | -------------------------------------------------------------------------------- /hook/hooker.go: -------------------------------------------------------------------------------- 1 | package hook 2 | 3 | type HookCallback func(params ...interface{}) 4 | 5 | var hooks = make(map[int][]*HookCallback) 6 | 7 | func RegisterHook(hookType int, cb HookCallback) { 8 | if currentHooks, ok := hooks[hookType]; ok { 9 | hooks[hookType] = append(currentHooks, &cb) 10 | } else { 11 | hooks[hookType] = []*HookCallback{&cb} 12 | } 13 | } 14 | 15 | func RegisterHooks(hookTypes []int, cb HookCallback) { 16 | for _, hookType := range hookTypes { 17 | RegisterHook(hookType, cb) 18 | } 19 | } 20 | 21 | func CallHooks(hookType int, params ...interface{}) { 22 | if hooks, ok := hooks[hookType]; ok { 23 | for _, hook := range hooks { 24 | (*hook)(params...) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /hook/hooks.go: -------------------------------------------------------------------------------- 1 | package hook 2 | 3 | const ( 4 | HOOK_PLAYER_INITIALIZED = iota 5 | HOOK_RESULT_FETCH_STARTED 6 | HOOK_FILE_LOAD_STARTED 7 | HOOK_FILE_LOADED 8 | HOOK_FILE_ENDED 9 | HOOK_FILE_APPENDED 10 | HOOK_PLAYBACK_PAUSED 11 | HOOK_PLAYBACK_RESUMED 12 | HOOK_VOLUME_CHANGED 13 | HOOK_POSITION_CHANGED 14 | HOOK_RESULT_DOWNLOAD_STARTED 15 | HOOK_QUEUE_UPDATE_FINISHED 16 | HOOK_PLAYER_EXIT 17 | HOOK_LOOP_STATUS_CHANGED 18 | 19 | HOOK_GUI_STARTED 20 | 21 | // communicate the tray with the gtk window 22 | HOOK_REQUEST_EXIT 23 | HOOK_REQUEST_SHOW_HIDE 24 | ) 25 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "time" 8 | 9 | "github.com/Pauloo27/neptune/db" 10 | "github.com/Pauloo27/neptune/gui/app" 11 | "github.com/Pauloo27/neptune/hook" 12 | "github.com/Pauloo27/neptune/player" 13 | "github.com/Pauloo27/neptune/trayicon" 14 | "github.com/Pauloo27/neptune/utils" 15 | "github.com/Pauloo27/neptune/version" 16 | ) 17 | 18 | func main() { 19 | fmt.Printf("Starting neptune %s\n", version.VERSION) 20 | 21 | // load data folder 22 | home, err := os.UserHomeDir() 23 | utils.HandleError(err, "Cannot get user home") 24 | 25 | dataFolder := path.Join(home, ".cache", "neptune") 26 | 27 | _, err = os.Stat(dataFolder) 28 | if os.IsNotExist(err) { 29 | err = os.MkdirAll(dataFolder, 0744) 30 | utils.HandleError(err, "Cannot create data folder") 31 | } 32 | 33 | albumsCacheFolder := path.Join(dataFolder, "albums") 34 | _, err = os.Stat(albumsCacheFolder) 35 | if os.IsNotExist(err) { 36 | err = os.MkdirAll(albumsCacheFolder, 0744) 37 | utils.HandleError(err, "Cannot create albums cache folder") 38 | } 39 | 40 | // conect to db 41 | db.Connect(dataFolder) 42 | 43 | // save the current version (used in migrations) 44 | prevVersion, err := db.LogStartup(version.VERSION) 45 | utils.HandleError(err, "Cannot log current version to db") 46 | 47 | version.MigrateFrom(prevVersion) 48 | 49 | // add hook (not useful yet) 50 | hook.RegisterHook(hook.HOOK_PLAYER_INITIALIZED, func(params ...interface{}) { 51 | fmt.Println("The player was initialized") 52 | }) 53 | 54 | // start backend player 55 | player.Initialize(dataFolder) 56 | 57 | hook.RegisterHook(hook.HOOK_GUI_STARTED, func(params ...interface{}) { 58 | go func() { 59 | // to avoid random crash 60 | // w t f 61 | time.Sleep(1 * time.Second) 62 | trayicon.LoadTrayIcon() 63 | }() 64 | }) 65 | 66 | // start gui 67 | app.Start(func() { 68 | player.Exit() 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /player/events.go: -------------------------------------------------------------------------------- 1 | package player 2 | 3 | import ( 4 | "fmt" 5 | "unsafe" 6 | 7 | "github.com/Pauloo27/neptune/hook" 8 | "github.com/Pauloo27/neptune/player/mpv" 9 | "github.com/Pauloo27/neptune/utils" 10 | ) 11 | 12 | func handlePropertyChange(data *mpv.EventProperty) { 13 | switch data.Name { 14 | case "volume": 15 | volume := *(*float64)(data.Data.(unsafe.Pointer)) 16 | hook.CallHooks(hook.HOOK_VOLUME_CHANGED, volume) 17 | case "loop-file": 18 | if data.Data.(unsafe.Pointer) == nil { 19 | State.loopFile = true 20 | } else { 21 | State.loopFile = *(*bool)(data.Data.(unsafe.Pointer)) 22 | } 23 | hook.CallHooks(hook.HOOK_LOOP_STATUS_CHANGED) 24 | case "loop-playlist": 25 | if data.Data.(unsafe.Pointer) == nil { 26 | State.loopPlaylist = true 27 | } else { 28 | State.loopPlaylist = *(*bool)(data.Data.(unsafe.Pointer)) 29 | } 30 | hook.CallHooks(hook.HOOK_LOOP_STATUS_CHANGED) 31 | default: 32 | fmt.Printf("Property %s changed\n", data.Name) 33 | } 34 | } 35 | 36 | func startEventHandler() { 37 | go func() { 38 | for { 39 | event := MpvInstance.WaitEvent(60) 40 | switch event.Event_Id { 41 | case mpv.EVENT_NONE: 42 | continue 43 | case mpv.EVENT_PROPERTY_CHANGE: 44 | data := event.Data.(*mpv.EventProperty) 45 | handlePropertyChange(data) 46 | case mpv.EVENT_FILE_LOADED: 47 | duration, err := MpvInstance.GetProperty("duration", mpv.FORMAT_DOUBLE) 48 | utils.HandleError(err, "Cannot get duration") 49 | State.Duration = duration.(float64) 50 | hook.CallHooks(hook.HOOK_FILE_LOADED, err, duration) 51 | case mpv.EVENT_PAUSE: 52 | State.Paused = true 53 | hook.CallHooks(hook.HOOK_PLAYBACK_PAUSED) 54 | case mpv.EVENT_END_FILE: 55 | hook.CallHooks(hook.HOOK_FILE_ENDED) 56 | case mpv.EVENT_UNPAUSE: 57 | State.Paused = false 58 | hook.CallHooks(hook.HOOK_PLAYBACK_RESUMED) 59 | default: 60 | fmt.Println(event) 61 | } 62 | } 63 | }() 64 | } 65 | -------------------------------------------------------------------------------- /player/loop_status.go: -------------------------------------------------------------------------------- 1 | package player 2 | 3 | type LoopStatus int 4 | 5 | const ( 6 | LOOP_NONE = LoopStatus(0) 7 | LOOP_TRACK = LoopStatus(1) 8 | LOOP_QUEUE = LoopStatus(2) 9 | ) 10 | -------------------------------------------------------------------------------- /player/main.go: -------------------------------------------------------------------------------- 1 | package player 2 | 3 | import ( 4 | "math" 5 | "math/rand" 6 | "time" 7 | 8 | "github.com/Pauloo27/neptune/db" 9 | "github.com/Pauloo27/neptune/hook" 10 | "github.com/Pauloo27/neptune/player/mpv" 11 | "github.com/Pauloo27/neptune/utils" 12 | ) 13 | 14 | const ( 15 | maxVolume = 150.0 16 | ) 17 | 18 | var MpvInstance *mpv.Mpv 19 | var State *PlayerState 20 | var DataFolder string 21 | 22 | func Initialize(dataFolder string) { 23 | var err error 24 | 25 | DataFolder = dataFolder 26 | 27 | initialVolume := 50.0 28 | 29 | // create a mpv instance 30 | MpvInstance = mpv.Create() 31 | 32 | // set options 33 | // disable video 34 | err = MpvInstance.SetOptionString("video", "no") 35 | utils.HandleError(err, "Cannot set mpv video option") 36 | 37 | // disable cache 38 | err = MpvInstance.SetOptionString("cache", "no") 39 | utils.HandleError(err, "Cannot set mpv cache option") 40 | 41 | // set default volume value 42 | err = MpvInstance.SetOption("volume", mpv.FORMAT_DOUBLE, initialVolume) 43 | utils.HandleError(err, "Cannot set mpv volume option") 44 | 45 | // set default volume value 46 | err = MpvInstance.SetOption("volume-max", mpv.FORMAT_DOUBLE, maxVolume) 47 | utils.HandleError(err, "Cannot set mpv volume-max option") 48 | 49 | // set quality to worst 50 | err = MpvInstance.SetOptionString("ytdl-format", "worst") 51 | utils.HandleError(err, "Cannot set mpv ytdl-format option") 52 | 53 | // add observers 54 | err = MpvInstance.ObserveProperty(0, "volume", mpv.FORMAT_DOUBLE) 55 | utils.HandleError(err, "Cannot observer volume property") 56 | err = MpvInstance.ObserveProperty(0, "loop-file", mpv.FORMAT_FLAG) 57 | utils.HandleError(err, "Cannot observer loop-file property") 58 | err = MpvInstance.ObserveProperty(0, "loop-playlist", mpv.FORMAT_FLAG) 59 | utils.HandleError(err, "Cannot observer loop-playlist property") 60 | 61 | // start event listener 62 | startEventHandler() 63 | 64 | // create the state 65 | State = &PlayerState{ 66 | false, 67 | nil, 68 | nil, 69 | 0, 70 | initialVolume, 71 | 0.0, 72 | false, false, 73 | } 74 | 75 | // internal hooks 76 | hook.RegisterHook(hook.HOOK_FILE_LOAD_STARTED, func(params ...interface{}) { 77 | Play() 78 | }) 79 | hook.RegisterHook(hook.HOOK_VOLUME_CHANGED, func(params ...interface{}) { 80 | State.Volume = params[0].(float64) 81 | }) 82 | hook.RegisterHook(hook.HOOK_FILE_ENDED, func(params ...interface{}) { 83 | index, err := MpvInstance.GetProperty("playlist-pos", mpv.FORMAT_INT64) 84 | if err != nil { 85 | utils.HandleError(err, "Cannot get playlist-pos") 86 | } 87 | State.QueueIndex = int(index.(int64)) 88 | if State.QueueIndex == -1 { 89 | State.QueueIndex = 0 90 | } 91 | }) 92 | 93 | // start the player 94 | err = MpvInstance.Initialize() 95 | utils.HandleError(err, "Cannot initialize mpv") 96 | 97 | hook.CallHooks(hook.HOOK_PLAYER_INITIALIZED, err) 98 | } 99 | 100 | func PlayTrack(track *db.Track) { 101 | clearQueue() 102 | 103 | addToTopOfQueue(track) 104 | loadFile(track.GetPath()) 105 | 106 | hook.CallHooks(hook.HOOK_QUEUE_UPDATE_FINISHED) 107 | } 108 | 109 | func PlayTracks(tracks []*db.Track) { 110 | clearQueue() 111 | 112 | if len(tracks) == 0 { 113 | return 114 | } 115 | 116 | addToTopOfQueue(tracks[0]) 117 | loadFile(tracks[0].GetPath()) 118 | 119 | for _, track := range tracks[1:] { 120 | addToQueue(track) 121 | appendFile(track.GetPath()) 122 | } 123 | hook.CallHooks(hook.HOOK_QUEUE_UPDATE_FINISHED) 124 | } 125 | 126 | func AddTrackToQueue(track *db.Track) { 127 | addToQueue(track) 128 | appendFile(track.GetPath()) 129 | hook.CallHooks(hook.HOOK_QUEUE_UPDATE_FINISHED) 130 | } 131 | 132 | func GetTrackAt(index int) *db.Track { 133 | if index >= len(State.Queue) { 134 | return nil 135 | } 136 | return State.Queue[index] 137 | } 138 | 139 | func GetCurrentTrack() *db.Track { 140 | return GetTrackAt(State.QueueIndex) 141 | } 142 | 143 | func addToQueue(track *db.Track) { 144 | State.Queue = append(State.Queue, track) 145 | } 146 | 147 | func addToTopOfQueue(track *db.Track) { 148 | newQueue := []*db.Track{track} 149 | newQueue = append(newQueue, State.Queue...) 150 | State.Queue = newQueue 151 | } 152 | 153 | func moveInQueue(index int, up bool) error { 154 | offset := 1 155 | if up { 156 | offset = -1 157 | } 158 | State.Queue[index+offset], State.Queue[index] = State.Queue[index], State.Queue[index+offset] 159 | 160 | var from, to int 161 | if up { 162 | from, to = index, index+offset 163 | } else { 164 | to, from = index, index+offset 165 | } 166 | return MpvInstance.CommandString(utils.Fmt("playlist-move %d %d", from, to)) 167 | } 168 | 169 | func MoveUpInQueue(index int) error { 170 | if index == 0 { 171 | return nil 172 | } 173 | err := moveInQueue(index, true) 174 | if err != nil { 175 | return err 176 | } 177 | hook.CallHooks(hook.HOOK_QUEUE_UPDATE_FINISHED) 178 | return nil 179 | } 180 | 181 | func MoveDownInQueue(index int) error { 182 | if index >= len(State.Queue)-1 { 183 | return nil 184 | } 185 | err := moveInQueue(index, false) 186 | if err != nil { 187 | return err 188 | } 189 | hook.CallHooks(hook.HOOK_QUEUE_UPDATE_FINISHED) 190 | return nil 191 | } 192 | 193 | func RemoveFromQueue(index int) error { 194 | if index >= len(State.Queue) { 195 | return nil 196 | } 197 | newQueue := []*db.Track{} 198 | for i := 0; i < len(State.Queue); i++ { 199 | if i == index { 200 | continue 201 | } 202 | newQueue = append(newQueue, GetTrackAt(i)) 203 | } 204 | State.Queue = newQueue 205 | err := MpvInstance.CommandString(utils.Fmt("playlist-remove %d", index)) 206 | if err != nil { 207 | return err 208 | } 209 | hook.CallHooks(hook.HOOK_QUEUE_UPDATE_FINISHED) 210 | return nil 211 | } 212 | 213 | func ClearQueue() error { 214 | err := clearQueue() 215 | if err != nil { 216 | return err 217 | } 218 | hook.CallHooks(hook.HOOK_QUEUE_UPDATE_FINISHED) 219 | return nil 220 | } 221 | 222 | func clearQueue() error { 223 | State.Queue = []*db.Track{} 224 | return clearEntirePlaylist() 225 | } 226 | 227 | func clearEntirePlaylist() error { 228 | err := trimPlaylist() 229 | if err != nil { 230 | return err 231 | } 232 | return removeCurrentFromPlaylist() 233 | } 234 | 235 | func trimPlaylist() error { 236 | return MpvInstance.Command([]string{"playlist-clear"}) 237 | } 238 | 239 | func removeCurrentFromPlaylist() error { 240 | return MpvInstance.Command([]string{"playlist-remove", "current"}) 241 | } 242 | 243 | func loadFile(filePath string) error { 244 | loadMPRIS() 245 | err := MpvInstance.Command([]string{"loadfile", filePath}) 246 | hook.CallHooks(hook.HOOK_FILE_LOAD_STARTED, err, filePath) 247 | return err 248 | } 249 | 250 | func appendFile(filePath string) error { 251 | loadMPRIS() 252 | err := MpvInstance.Command([]string{"loadfile", filePath, "append"}) 253 | hook.CallHooks(hook.HOOK_FILE_APPENDED, err, filePath) 254 | return err 255 | } 256 | 257 | func appendFileAndPlay(filePath string) error { 258 | loadMPRIS() 259 | err := MpvInstance.Command([]string{"loadfile", filePath, "append-play"}) 260 | hook.CallHooks(hook.HOOK_FILE_APPENDED, err, filePath) 261 | return err 262 | } 263 | 264 | func Stop() error { 265 | return MpvInstance.Command([]string{"stop"}) 266 | } 267 | 268 | func PlayPause() error { 269 | if State.Paused { 270 | return Play() 271 | } else { 272 | return Pause() 273 | } 274 | } 275 | 276 | func Pause() error { 277 | return MpvInstance.SetProperty("pause", mpv.FORMAT_FLAG, true) 278 | } 279 | 280 | func Play() error { 281 | return MpvInstance.SetProperty("pause", mpv.FORMAT_FLAG, false) 282 | } 283 | 284 | func SetVolume(volume float64) error { 285 | volume = math.Min(maxVolume, volume) 286 | err := MpvInstance.SetProperty("volume", mpv.FORMAT_DOUBLE, volume) 287 | return err 288 | } 289 | 290 | func GetPosition() (float64, error) { 291 | position, err := MpvInstance.GetProperty("time-pos", mpv.FORMAT_DOUBLE) 292 | if err != nil { 293 | return 0.0, err 294 | } 295 | return position.(float64), err 296 | } 297 | 298 | func SetPosition(pos float64) error { 299 | err := MpvInstance.SetProperty("time-pos", mpv.FORMAT_DOUBLE, pos) 300 | hook.CallHooks(hook.HOOK_POSITION_CHANGED, err, pos) 301 | return err 302 | } 303 | 304 | func setCurrentTrackID(id int) error { 305 | return MpvInstance.SetProperty("playlist-pos", mpv.FORMAT_INT64, id) 306 | } 307 | 308 | func NextLoopStatus() error { 309 | newLoopStatus := LOOP_NONE 310 | switch GetLoopStatus() { 311 | case LOOP_NONE: 312 | newLoopStatus = LOOP_TRACK 313 | case LOOP_TRACK: 314 | newLoopStatus = LOOP_QUEUE 315 | } 316 | return SetLoopStatus(newLoopStatus) 317 | } 318 | 319 | func SetLoopStatus(loopStatus LoopStatus) error { 320 | track, queue := false, false 321 | 322 | if loopStatus == LOOP_TRACK { 323 | track = true 324 | } 325 | if loopStatus == LOOP_QUEUE { 326 | queue = true 327 | } 328 | 329 | err := MpvInstance.SetProperty("loop-file", mpv.FORMAT_FLAG, track) 330 | if err != nil { 331 | return err 332 | } 333 | return MpvInstance.SetProperty("loop-playlist", mpv.FORMAT_FLAG, queue) 334 | } 335 | 336 | func GetLoopStatus() LoopStatus { 337 | if State.loopFile { 338 | return LOOP_TRACK 339 | } 340 | if State.loopPlaylist { 341 | return LOOP_QUEUE 342 | } 343 | return LOOP_NONE 344 | } 345 | 346 | func Shuffle() { 347 | newQueue := State.Queue 348 | 349 | rand.Seed(time.Now().Unix()) 350 | rand.Shuffle(len(State.Queue), func(i, j int) { 351 | newQueue[i], newQueue[j] = newQueue[j], newQueue[i] 352 | }) 353 | 354 | PlayTracks(newQueue) 355 | } 356 | 357 | func PreviousTrack() error { 358 | return MpvInstance.Command([]string{"playlist-prev"}) 359 | } 360 | 361 | func Exit() error { 362 | hook.CallHooks(hook.HOOK_PLAYER_EXIT) 363 | return MpvInstance.CommandString("exit 0") 364 | } 365 | 366 | func NextTrack() error { 367 | return MpvInstance.Command([]string{"playlist-next"}) 368 | } 369 | -------------------------------------------------------------------------------- /player/mpris.go: -------------------------------------------------------------------------------- 1 | package player 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/Pauloo27/neptune/utils" 7 | ) 8 | 9 | var ( 10 | userHomePaths = []string{"/.config/mpv/scripts/mpris.so"} 11 | systemPaths = []string{"/etc/mpv/scripts/mpris.so"} 12 | loaded = false 13 | ) 14 | 15 | func loadMPRIS() { 16 | if loaded { 17 | return 18 | } 19 | loadScript := func(path string) bool { 20 | if _, err := os.Stat(path); os.IsNotExist(err) { 21 | return false 22 | } 23 | 24 | err := MpvInstance.Command([]string{"load-script", path}) 25 | if err != nil { 26 | utils.HandleError(err, "Cannot load mpris script at "+path) 27 | } 28 | return true 29 | } 30 | 31 | defer func() { loaded = true }() 32 | 33 | home := utils.GetUserHome() 34 | for _, path := range userHomePaths { 35 | if loadScript(home + path) { 36 | return 37 | } 38 | } 39 | 40 | for _, path := range systemPaths { 41 | if loadScript(path) { 42 | return 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /player/mpv/consts.go: -------------------------------------------------------------------------------- 1 | package mpv 2 | 3 | //#include 4 | import "C" 5 | 6 | //Errors mpv_error 7 | const ( 8 | /** 9 | * No error happened (used to signal successful operation). 10 | * Keep in mind that many API functions returning error codes can also 11 | * return positive values, which also indicate success. API users can 12 | * hardcode the fact that ">= 0" means success. 13 | */ 14 | ERROR_SUCCESS Error = C.MPV_ERROR_SUCCESS 15 | /** 16 | * The event ringbuffer is full. This means the client is choked, and can't 17 | * receive any events. This can happen when too many asynchronous requests 18 | * have been made, but not answered. Probably never happens in practice, 19 | * unless the mpv core is frozen for some reason, and the client keeps 20 | * making asynchronous requests. (Bugs in the client API implementation 21 | * could also trigger this, e.g. if events become "lost".) 22 | */ 23 | ERROR_EVENT_QUEUE_FULL Error = C.MPV_ERROR_EVENT_QUEUE_FULL 24 | /** 25 | * Memory allocation failed. 26 | */ 27 | ERROR_NOMEM Error = C.MPV_ERROR_NOMEM 28 | /** 29 | * The mpv core wasn't configured and initialized yet. See the notes in 30 | * mpv_create(). 31 | */ 32 | ERROR_UNINITIALIZED Error = C.MPV_ERROR_UNINITIALIZED 33 | /** 34 | * Generic catch-all error if a parameter is set to an invalid or 35 | * unsupported value. This is used if there is no better error code. 36 | */ 37 | ERROR_INVALID_PARAMETER Error = C.MPV_ERROR_INVALID_PARAMETER 38 | /** 39 | * Trying to set an option that doesn't exist. 40 | */ 41 | ERROR_OPTION_NOT_FOUND Error = C.MPV_ERROR_OPTION_NOT_FOUND 42 | /** 43 | * Trying to set an option using an unsupported MPV_FORMAT. 44 | */ 45 | ERROR_OPTION_FORMAT Error = C.MPV_ERROR_OPTION_FORMAT 46 | /** 47 | * Setting the option failed. Typically this happens if the provided option 48 | * value could not be parsed. 49 | */ 50 | ERROR_OPTION_ERROR Error = C.MPV_ERROR_OPTION_ERROR 51 | /** 52 | * The accessed property doesn't exist. 53 | */ 54 | ERROR_PROPERTY_NOT_FOUND Error = C.MPV_ERROR_PROPERTY_NOT_FOUND 55 | /** 56 | * Trying to set or get a property using an unsupported MPV_FORMAT. 57 | */ 58 | ERROR_PROPERTY_FORMAT Error = C.MPV_ERROR_PROPERTY_FORMAT 59 | /** 60 | * The property exists, but is not available. This usually happens when the 61 | * associated subsystem is not active, e.g. querying audio parameters while 62 | * audio is disabled. 63 | */ 64 | ERROR_PROPERTY_UNAVAILABLE Error = C.MPV_ERROR_PROPERTY_UNAVAILABLE 65 | /** 66 | * Error setting or getting a property. 67 | */ 68 | ERROR_PROPERTY_ERROR Error = C.MPV_ERROR_PROPERTY_ERROR 69 | /** 70 | * General error when running a command with mpv_command and similar. 71 | */ 72 | ERROR_COMMAND Error = C.MPV_ERROR_COMMAND 73 | /** 74 | * Generic error on loading (used with mpv_event_end_file.error). 75 | */ 76 | ERROR_LOADING_FAILED Error = C.MPV_ERROR_LOADING_FAILED 77 | /** 78 | * Initializing the audio output failed. 79 | */ 80 | ERROR_AO_INIT_FAILED Error = C.MPV_ERROR_AO_INIT_FAILED 81 | /** 82 | * Initializing the video output failed. 83 | */ 84 | ERROR_VO_INIT_FAILED Error = C.MPV_ERROR_VO_INIT_FAILED 85 | /** 86 | * There was no audio or video data to play. This also happens if the 87 | * file was recognized, but did not contain any audio or video streams, 88 | * or no streams were selected. 89 | */ 90 | ERROR_NOTHING_TO_PLAY Error = C.MPV_ERROR_NOTHING_TO_PLAY 91 | /** 92 | * When trying to load the file, the file format could not be determined, 93 | * or the file was too broken to open it. 94 | */ 95 | ERROR_UNKNOWN_FORMAT Error = C.MPV_ERROR_UNKNOWN_FORMAT 96 | /** 97 | * Generic error for signaling that certain system requirements are not 98 | * fulfilled. 99 | */ 100 | ERROR_UNSUPPORTED Error = C.MPV_ERROR_UNSUPPORTED 101 | /** 102 | * The API function which was called is a stub only. 103 | */ 104 | MPV_ERROR_NOT_IMPLEMENTED Error = C.MPV_ERROR_UNSUPPORTED 105 | ) 106 | 107 | type Format int 108 | 109 | //Format mpv_format 110 | const ( 111 | FORMAT_NONE Format = C.MPV_FORMAT_NONE 112 | /** 113 | * The basic type is char*. It returns the raw property string, like 114 | * using ${=property} in input.conf (see input.rst). 115 | * 116 | * NULL isn't an allowed value. 117 | * 118 | * Warning: although the encoding is usually UTF-8, this is not always the 119 | * case. File tags often store strings in some legacy codepage, 120 | * and even filenames don't necessarily have to be in UTF-8 (at 121 | * least on Linux). If you pass the strings to code that requires 122 | * valid UTF-8, you have to sanitize it in some way. 123 | * On Windows, filenames are always UTF-8, and libmpv converts 124 | * between UTF-8 and UTF-16 when using win32 API functions. See 125 | * the "Encoding of filenames" section for details. 126 | * 127 | * Example for reading: 128 | * 129 | * char *result = NULL; 130 | * if (mpv_get_property(ctx, "property", FORMAT_STRING, = C.MPV_FORMAT_STRING, 131 | * goto error; 132 | * printf("%s\n", result); 133 | * mpv_free(result); 134 | * 135 | * Or just use mpv_get_property_string(). 136 | * 137 | * Example for writing: 138 | * 139 | * char *value = "the new value"; 140 | * // yep, you pass the address to the variable 141 | * // (needed for symmetry with other types and mpv_get_property) 142 | * mpv_set_property(ctx, "property", FORMAT_STRING, = C.MPV_FORMAT_STRING, 143 | * 144 | * Or just use mpv_set_property_string(). 145 | * 146 | */ 147 | FORMAT_STRING Format = C.MPV_FORMAT_STRING 148 | /** 149 | * The basic type is char*. It returns the OSD property string, like 150 | * using ${property} in input.conf (see input.rst). In many cases, this 151 | * is the same as the raw string, but in other cases it's formatted for 152 | * display on OSD. It's intended to be human readable. Do not attempt to 153 | * parse these strings. 154 | * 155 | * Only valid when doing read access. The rest works like MPV_FORMAT_STRING. 156 | */ 157 | FORMAT_OSD_STRING Format = C.MPV_FORMAT_OSD_STRING 158 | /** 159 | * The basic type is int. The only allowed values are 0 ("no") 160 | * and 1 ("yes"). 161 | * 162 | * Example for reading: 163 | * 164 | * int result; 165 | * if (mpv_get_property(ctx, "property", FORMAT_FLAG, = C.MPV_FORMAT_FLAG, 166 | * goto error; 167 | * printf("%s\n", result ? "true" : "false"); 168 | * 169 | * Example for writing: 170 | * 171 | * int flag = 1; 172 | * mpv_set_property(ctx, "property", FORMAT_STRING, = C.MPV_FORMAT_STRING, 173 | */ 174 | FORMAT_FLAG Format = C.MPV_FORMAT_FLAG 175 | /** 176 | * The basic type is int64_t. 177 | */ 178 | FORMAT_INT64 Format = C.MPV_FORMAT_INT64 179 | /** 180 | * The basic type is double. 181 | */ 182 | FORMAT_DOUBLE Format = C.MPV_FORMAT_DOUBLE 183 | /** 184 | * The type is mpv_node. 185 | * 186 | * For reading, you usually would pass a pointer to a stack-allocated 187 | * mpv_node value to mpv, and when you're done you call 188 | * mpv_free_node_contents(&node). 189 | * You're expected not to write to the data - if you have to, copy it 190 | * first (which you have to do manually). 191 | * 192 | * For writing, you construct your own mpv_node, and pass a pointer to the 193 | * API. The API will never write to your data (and copy it if needed), so 194 | * you're free to use any form of allocation or memory management you like. 195 | * 196 | * Warning: when reading, always check the mpv_node.format member. For 197 | * example, properties might change their type in future versions 198 | * of mpv, or sometimes even during runtime. 199 | * 200 | * Example for reading: 201 | * 202 | * mpv_node result; 203 | * if (mpv_get_property(ctx, "property", FORMAT_NODE, = C.MPV_FORMAT_NODE, 204 | * goto error; 205 | * printf("format=%d\n", (int)result.format); 206 | * mpv_free_node_contents(&result). 207 | * 208 | * Example for writing: 209 | * 210 | * mpv_node value; 211 | * value.format = MPV_FORMAT_STRING; 212 | * value.u.string = "hello"; 213 | * mpv_set_property(ctx, "property", FORMAT_NODE, = C.MPV_FORMAT_NODE, 214 | */ 215 | FORMAT_NODE Format = C.MPV_FORMAT_NODE 216 | /** 217 | * Used with mpv_node only. Can usually not be used directly. 218 | */ 219 | FORMAT_NODE_ARRAY Format = C.MPV_FORMAT_NODE_ARRAY 220 | /** 221 | * See MPV_FORMAT_NODE_ARRAY. 222 | */ 223 | FORMAT_NODE_MAP Format = C.MPV_FORMAT_NODE_MAP 224 | /** 225 | * A raw, untyped byte array. Only used only with mpv_node, and only in 226 | * some very special situations. (Currently, only for the screenshot_raw 227 | * command.) 228 | */ 229 | FORMAT_BYTE_ARRAY = C.MPV_FORMAT_BYTE_ARRAY 230 | ) 231 | 232 | type EventId int 233 | 234 | //EventId mpv_event_id 235 | const ( 236 | /** 237 | * Nothing happened. Happens on timeouts or sporadic wakeups. 238 | */ 239 | EVENT_NONE EventId = C.MPV_EVENT_NONE 240 | /** 241 | * Happens when the player quits. The player enters a state where it tries 242 | * to disconnect all clients. Most requests to the player will fail, and 243 | * mpv_wait_event() will always return instantly (returning new shutdown 244 | * events if no other events are queued). The client should react to this 245 | * and quit with mpv_detach_destroy() as soon as possible. 246 | */ 247 | EVENT_SHUTDOWN EventId = C.MPV_EVENT_SHUTDOWN 248 | /** 249 | * See mpv_request_log_messages(). 250 | */ 251 | EVENT_LOG_MESSAGE EventId = C.MPV_EVENT_LOG_MESSAGE 252 | /** 253 | * Reply to a mpv_get_property_async() request. 254 | * See also mpv_event and mpv_event_property. 255 | */ 256 | EVENT_GET_PROPERTY_REPLY EventId = C.MPV_EVENT_GET_PROPERTY_REPLY 257 | /** 258 | * Reply to a mpv_set_property_async() request. 259 | * (Unlike EVENT_GET_PROPERTY, = C.MPV_EVENT_GET_PROPERTY, 260 | */ 261 | EVENT_SET_PROPERTY_REPLY EventId = C.MPV_EVENT_SET_PROPERTY_REPLY 262 | /** 263 | * Reply to a mpv_command_async() request. 264 | */ 265 | EVENT_COMMAND_REPLY EventId = C.MPV_EVENT_COMMAND_REPLY 266 | /** 267 | * Notification before playback start of a file (before the file is loaded). 268 | */ 269 | EVENT_START_FILE EventId = C.MPV_EVENT_START_FILE 270 | /** 271 | * Notification after playback end (after the file was unloaded). 272 | * See also mpv_event and mpv_event_end_file. 273 | */ 274 | EVENT_END_FILE EventId = C.MPV_EVENT_END_FILE 275 | /** 276 | * Notification when the file has been loaded (headers were read etc.), and 277 | * decoding starts. 278 | */ 279 | EVENT_FILE_LOADED EventId = C.MPV_EVENT_FILE_LOADED 280 | /** 281 | * The list of video/audio/subtitle tracks was changed. (E.g. a new track 282 | * was found. This doesn't necessarily indicate a track switch; for this, 283 | * EVENT_TRACK_SWITCHED = C.MPV_EVENT_TRACK_SWITCHED 284 | * 285 | * @deprecated This is equivalent to using mpv_observe_property() on the 286 | * "track-list" property. The event is redundant, and might 287 | * be removed in the far future. 288 | */ 289 | EVENT_TRACKS_CHANGED EventId = C.MPV_EVENT_TRACKS_CHANGED 290 | /** 291 | * A video/audio/subtitle track was switched on or off. 292 | * 293 | * @deprecated This is equivalent to using mpv_observe_property() on the 294 | * "vid", "aid", and "sid" properties. The event is redundant, 295 | * and might be removed in the far future. 296 | */ 297 | EVENT_TRACK_SWITCHED EventId = C.MPV_EVENT_TRACK_SWITCHED 298 | /** 299 | * Idle mode was entered. In this mode, no file is played, and the playback 300 | * core waits for new commands. (The command line player normally quits 301 | * instead of entering idle mode, unless --idle was specified. If mpv 302 | * was started with mpv_create(), idle mode is enabled by default.) 303 | */ 304 | EVENT_IDLE EventId = C.MPV_EVENT_IDLE 305 | /** 306 | * Playback was paused. This indicates the user pause state. 307 | * 308 | * The user pause state is the state the user requested (changed with the 309 | * "pause" property). There is an internal pause state too, which is entered 310 | * if e.g. the network is too slow (the "core-idle" property generally 311 | * indicates whether the core is playing or waiting). 312 | * 313 | * This event is sent whenever any pause states change, not only the user 314 | * state. You might get multiple events in a row while these states change 315 | * independently. But the event ID sent always indicates the user pause 316 | * state. 317 | * 318 | * If you don't want to deal with this, use mpv_observe_property() on the 319 | * "pause" property and ignore EVENT_PAUSE/UNPAUSE. = C.MPV_EVENT_PAUSE/UNPAUSE. 320 | * "core-idle" property tells you whether video is actually playing or not. 321 | * 322 | * @deprecated The event is redundant with mpv_observe_property() as 323 | * mentioned above, and might be removed in the far future. 324 | */ 325 | EVENT_PAUSE EventId = C.MPV_EVENT_PAUSE 326 | /** 327 | * Playback was unpaused. See EVENT_PAUSE = C.MPV_EVENT_PAUSE 328 | * 329 | * @deprecated The event is redundant with mpv_observe_property() as 330 | * explained in the EVENT_PAUSE = C.MPV_EVENT_PAUSE 331 | * removed in the far future. 332 | */ 333 | EVENT_UNPAUSE EventId = C.MPV_EVENT_UNPAUSE 334 | /** 335 | * Sent every time after a video frame is displayed. Note that currently, 336 | * this will be sent in lower frequency if there is no video, or playback 337 | * is paused - but that will be removed in the future, and it will be 338 | * restricted to video frames only. 339 | */ 340 | EVENT_TICK EventId = C.MPV_EVENT_TICK 341 | /** 342 | * @deprecated This was used internally with the internal "script_dispatch" 343 | * command to dispatch keyboard and mouse input for the OSC. 344 | * It was never useful in general and has been completely 345 | * replaced with "script_binding". 346 | * This event never happens anymore, and is included in this 347 | * header only for compatibility. 348 | */ 349 | EVENT_SCRIPT_INPUT_DISPATCH EventId = C.MPV_EVENT_SCRIPT_INPUT_DISPATCH 350 | /** 351 | * Triggered by the script_message input command. The command uses the 352 | * first argument of the command as client name (see mpv_client_name()) to 353 | * dispatch the message, and passes along all arguments starting from the 354 | * second argument as strings. 355 | * See also mpv_event and mpv_event_client_message. 356 | */ 357 | EVENT_CLIENT_MESSAGE EventId = C.MPV_EVENT_CLIENT_MESSAGE 358 | /** 359 | * Happens after video changed in some way. This can happen on resolution 360 | * changes, pixel format changes, or video filter changes. The event is 361 | * sent after the video filters and the VO are reconfigured. Applications 362 | * embedding a mpv window should listen to this event in order to resize 363 | * the window if needed. 364 | * Note that this event can happen sporadically, and you should check 365 | * yourself whether the video parameters really changed before doing 366 | * something expensive. 367 | */ 368 | EVENT_VIDEO_RECONFIG EventId = C.MPV_EVENT_VIDEO_RECONFIG 369 | /** 370 | * Similar to EVENT_VIDEO_RECONFIG. = C.MPV_EVENT_VIDEO_RECONFIG. 371 | * because there is no such thing as audio output embedding. 372 | */ 373 | EVENT_AUDIO_RECONFIG EventId = C.MPV_EVENT_AUDIO_RECONFIG 374 | /** 375 | * Happens when metadata (like file tags) is possibly updated. (It's left 376 | * unspecified whether this happens on file start or only when it changes 377 | * within a file.) 378 | * 379 | * @deprecated This is equivalent to using mpv_observe_property() on the 380 | * "metadata" property. The event is redundant, and might 381 | * be removed in the far future. 382 | */ 383 | EVENT_METADATA_UPDATE EventId = C.MPV_EVENT_METADATA_UPDATE 384 | /** 385 | * Happens when a seek was initiated. Playback stops. Usually it will 386 | * resume with EVENT_PLAYBACK_RESTART = C.MPV_EVENT_PLAYBACK_RESTART 387 | */ 388 | EVENT_SEEK EventId = C.MPV_EVENT_SEEK 389 | /** 390 | * There was a discontinuity of some sort (like a seek), and playback 391 | * was reinitialized. Usually happens after seeking, or ordered chapter 392 | * segment switches. The main purpose is allowing the client to detect 393 | * when a seek request is finished. 394 | */ 395 | EVENT_PLAYBACK_RESTART EventId = C.MPV_EVENT_PLAYBACK_RESTART 396 | /** 397 | * Event sent due to mpv_observe_property(). 398 | * See also mpv_event and mpv_event_property. 399 | */ 400 | EVENT_PROPERTY_CHANGE EventId = C.MPV_EVENT_PROPERTY_CHANGE 401 | /** 402 | * Happens when the current chapter changes. 403 | * 404 | * @deprecated This is equivalent to using mpv_observe_property() on the 405 | * "chapter" property. The event is redundant, and might 406 | * be removed in the far future. 407 | */ 408 | EVENT_CHAPTER_CHANGE EventId = C.MPV_EVENT_CHAPTER_CHANGE 409 | /** 410 | * Happens if the internal per-mpv_handle ringbuffer overflows, and at 411 | * least 1 event had to be dropped. This can happen if the client doesn't 412 | * read the event queue quickly enough with mpv_wait_event(), or if the 413 | * client makes a very large number of asynchronous calls at once. 414 | * 415 | * Event delivery will continue normally once this event was returned 416 | * (this forces the client to empty the queue completely). 417 | */ 418 | EVENT_QUEUE_OVERFLOW EventId = C.MPV_EVENT_QUEUE_OVERFLOW 419 | // Internal note: adjust INTERNAL_EVENT_BASE when adding new events. 420 | ) 421 | 422 | func (eid EventId) String() string { 423 | switch eid { 424 | case EVENT_NONE: 425 | { 426 | return "EVENT_NONE" 427 | } 428 | case EVENT_SHUTDOWN: 429 | { 430 | return "EVENT_SHUTDOWN" 431 | } 432 | case EVENT_LOG_MESSAGE: 433 | { 434 | return "EVENT_LOG_MESSAGE" 435 | } 436 | case EVENT_GET_PROPERTY_REPLY: 437 | { 438 | return "EVENT_GET_PROPERTY_REPLY" 439 | } 440 | case EVENT_SET_PROPERTY_REPLY: 441 | { 442 | return "EVENT_SET_PROPERTY_REPLY" 443 | } 444 | case EVENT_COMMAND_REPLY: 445 | { 446 | return "EVENT_COMMAND_REPLY" 447 | } 448 | case EVENT_START_FILE: 449 | { 450 | return "EVENT_START_FILE" 451 | } 452 | case EVENT_END_FILE: 453 | { 454 | return "EVENT_END_FILE" 455 | } 456 | case EVENT_FILE_LOADED: 457 | { 458 | return "EVENT_FILE_LOADED" 459 | } 460 | case EVENT_TRACKS_CHANGED: 461 | { 462 | return "EVENT_TRACKS_CHANGED" 463 | } 464 | case EVENT_TRACK_SWITCHED: 465 | { 466 | return "EVENT_TRACK_SWITCHED" 467 | } 468 | case EVENT_IDLE: 469 | { 470 | return "EVENT_IDLE" 471 | } 472 | case EVENT_PAUSE: 473 | { 474 | return "EVENT_PAUSE" 475 | } 476 | case EVENT_UNPAUSE: 477 | { 478 | return "EVENT_UNPAUSE" 479 | } 480 | case EVENT_TICK: 481 | { 482 | return "EVENT_TICK" 483 | } 484 | case EVENT_SCRIPT_INPUT_DISPATCH: 485 | { 486 | return "EVENT_SCRIPT_INPUT_DISPATCH" 487 | } 488 | case EVENT_CLIENT_MESSAGE: 489 | { 490 | return "EVENT_CLIENT_MESSAGE" 491 | } 492 | case EVENT_VIDEO_RECONFIG: 493 | { 494 | return "EVENT_VIDEO_RECONFIG" 495 | } 496 | case EVENT_AUDIO_RECONFIG: 497 | { 498 | return "EVENT_AUDIO_RECONFIG" 499 | } 500 | case EVENT_METADATA_UPDATE: 501 | { 502 | return "EVENT_METADATA_UPDATE" 503 | } 504 | case EVENT_SEEK: 505 | { 506 | return "EVENT_SEEK" 507 | } 508 | case EVENT_PLAYBACK_RESTART: 509 | { 510 | return "EVENT_PLAYBACK_RESTART" 511 | } 512 | case EVENT_PROPERTY_CHANGE: 513 | { 514 | return "EVENT_PROPERTY_CHANGE" 515 | } 516 | case EVENT_CHAPTER_CHANGE: 517 | { 518 | return "EVENT_CHAPTER_CHANGE" 519 | } 520 | case EVENT_QUEUE_OVERFLOW: 521 | { 522 | return "EVENT_QUEUE_OVERFLOW" 523 | } 524 | } 525 | return "UNKNOWN_EVENT" 526 | } 527 | 528 | //Log level mpv_log_level 529 | const ( 530 | LOG_LEVEL_NONE = C.MPV_LOG_LEVEL_NONE /// "no" - disable absolutely all messages 531 | LOG_LEVEL_FATAL = C.MPV_LOG_LEVEL_FATAL /// "fatal" - critical/aborting errors 532 | LOG_LEVEL_ERROR = C.MPV_LOG_LEVEL_ERROR /// "error" - simple errors 533 | LOG_LEVEL_WARN = C.MPV_LOG_LEVEL_WARN /// "warn" - possible problems 534 | LOG_LEVEL_INFO = C.MPV_LOG_LEVEL_INFO /// "info" - informational message 535 | LOG_LEVEL_V = C.MPV_LOG_LEVEL_V /// "v" - noisy informational message 536 | LOG_LEVEL_DEBUG = C.MPV_LOG_LEVEL_DEBUG /// "debug" - very noisy technical information 537 | LOG_LEVEL_TRACE = C.MPV_LOG_LEVEL_TRACE /// "trace" - extremely noisy 538 | ) 539 | 540 | type EndFileReason int 541 | 542 | //EndFileReason mpv_end_file_reason 543 | const ( 544 | /** 545 | * The end of file was reached. Sometimes this may also happen on 546 | * incomplete or corrupted files, or if the network connection was 547 | * interrupted when playing a remote file. It also happens if the 548 | * playback range was restricted with --end or --frames or similar. 549 | */ 550 | END_FILE_REASON_EOF EndFileReason = C.MPV_END_FILE_REASON_EOF 551 | /** 552 | * Playback was stopped by an external action (e.g. playlist controls). 553 | */ 554 | END_FILE_REASON_STOP EndFileReason = C.MPV_END_FILE_REASON_STOP 555 | /** 556 | * Playback was stopped by the quit command or player shutdown. 557 | */ 558 | END_FILE_REASON_QUIT EndFileReason = C.MPV_END_FILE_REASON_QUIT 559 | /** 560 | * Some kind of error happened that lead to playback abort. Does not 561 | * necessarily happen on incomplete or broken files (in these cases, both 562 | * MPV_END_FILE_REASON_ERROR or MPV_END_FILE_REASON_EOF are possible). 563 | * 564 | * mpv_event_end_file.error will be set. 565 | */ 566 | END_FILE_REASON_ERROR EndFileReason = C.MPV_END_FILE_REASON_ERROR 567 | /** 568 | * The file was a playlist or similar. When the playlist is read, its 569 | * entries will be appended to the playlist after the entry of the current 570 | * file, the entry of the current file is removed, and a MPV_EVENT_END_FILE 571 | * event is sent with reason set to MPV_END_FILE_REASON_REDIRECT. Then 572 | * playback continues with the playlist contents. 573 | * Since API version 1.18. 574 | */ 575 | END_FILE_REASON_REDIRECT EndFileReason = C.MPV_END_FILE_REASON_REDIRECT 576 | ) 577 | 578 | func (efr EndFileReason) String() string { 579 | switch efr { 580 | case END_FILE_REASON_EOF: 581 | return "END_FILE_REASON_EOF" 582 | case END_FILE_REASON_STOP: 583 | return "END_FILE_REASON_STOP" 584 | case END_FILE_REASON_QUIT: 585 | return "END_FILE_REASON_QUIT" 586 | case END_FILE_REASON_ERROR: 587 | return "END_FILE_REASON_ERROR" 588 | case END_FILE_REASON_REDIRECT: 589 | return "END_FILE_REASON_REDIRECT" 590 | default: 591 | return "END_FILE_REASON_UNKNOWN" 592 | } 593 | } 594 | 595 | type SubApi int 596 | 597 | //mpv_sub_api 598 | const ( 599 | /** 600 | * For using mpv's OpenGL renderer on an external OpenGL context. 601 | * mpv_get_sub_api(MPV_SUB_API_OPENGL_CB) returns mpv_opengl_cb_context*. 602 | * This context can be used with mpv_opengl_cb_* functions. 603 | * Will return NULL if unavailable (if OpenGL support was not compiled in). 604 | * See opengl_cb.h for details. 605 | */ 606 | SUB_API_OPENGL_CB SubApi = C.MPV_SUB_API_OPENGL_CB 607 | ) 608 | -------------------------------------------------------------------------------- /player/mpv/error.go: -------------------------------------------------------------------------------- 1 | package mpv 2 | 3 | //#include 4 | import "C" 5 | 6 | import ( 7 | "fmt" 8 | ) 9 | 10 | type Error int 11 | 12 | //const char *mpv_error_string(int error); 13 | func NewError(errcode C.int) error { 14 | if errcode == C.MPV_ERROR_SUCCESS { 15 | return nil 16 | } 17 | return Error(errcode) 18 | } 19 | 20 | func (m Error) Error() string { 21 | return fmt.Sprintln(int(m), C.GoString(C.mpv_error_string(C.int(m)))) 22 | } 23 | -------------------------------------------------------------------------------- /player/mpv/mpv.go: -------------------------------------------------------------------------------- 1 | package mpv 2 | 3 | /* 4 | #include 5 | #include 6 | #cgo LDFLAGS: -lmpv 7 | 8 | char** makeCharArray1(int size) { 9 | return calloc(sizeof(char*), size); 10 | } 11 | void setArrayString1(char** a, int i, char* s) { 12 | a[i] = s; 13 | } 14 | 15 | */ 16 | import "C" 17 | 18 | import ( 19 | "unsafe" 20 | ) 21 | 22 | type Mpv struct { 23 | handle *C.mpv_handle 24 | wakeup_callbackVar interface{} 25 | wakeup_callbackFunc func(d interface{}) 26 | } 27 | 28 | func Create() *Mpv { 29 | ctx := C.mpv_create() 30 | if ctx == nil { 31 | return nil 32 | } 33 | return &Mpv{ctx, nil, nil} 34 | } 35 | 36 | func (m *Mpv) ClientName() string { 37 | return C.GoString(C.mpv_client_name(m.handle)) 38 | } 39 | 40 | func (m *Mpv) Initialize() error { 41 | return NewError(C.mpv_initialize(m.handle)) 42 | } 43 | 44 | func (m *Mpv) DetachDestroy() { 45 | C.mpv_detach_destroy(m.handle) 46 | } 47 | 48 | func (m *Mpv) TerminateDestroy() { 49 | C.mpv_terminate_destroy(m.handle) 50 | } 51 | 52 | func (m *Mpv) CreateClient(name string) *Mpv { 53 | cname := C.CString(name) 54 | defer C.free(unsafe.Pointer(cname)) 55 | cmpv := C.mpv_create_client(m.handle, cname) 56 | if cmpv != nil { 57 | return &Mpv{cmpv, nil, nil} 58 | } 59 | return nil 60 | } 61 | 62 | func (m *Mpv) LoadConfigFile(fileName string) error { 63 | cfn := C.CString(fileName) 64 | defer C.free(unsafe.Pointer(cfn)) 65 | return NewError(C.mpv_load_config_file(m.handle, cfn)) 66 | } 67 | 68 | func (m *Mpv) Suspend() { 69 | C.mpv_suspend(m.handle) 70 | } 71 | 72 | func (m *Mpv) Resume() { 73 | C.mpv_resume(m.handle) 74 | } 75 | 76 | func (m *Mpv) GetTimeUS() int64 { 77 | return int64(C.mpv_get_time_us(m.handle)) 78 | } 79 | 80 | func (m *Mpv) SetOption(name string, format Format, data interface{}) error { 81 | cname := C.CString(name) 82 | defer C.free(unsafe.Pointer(cname)) 83 | ptr := data2Ptr(format, data) 84 | return NewError(C.mpv_set_option(m.handle, cname, C.mpv_format(format), ptr)) 85 | } 86 | 87 | func (m *Mpv) SetOptionString(name, data string) error { 88 | cname := C.CString(name) 89 | cdata := C.CString(data) 90 | defer C.free(unsafe.Pointer(cname)) 91 | defer C.free(unsafe.Pointer(cdata)) 92 | return NewError(C.mpv_set_option_string(m.handle, cname, cdata)) 93 | } 94 | 95 | func (m *Mpv) Command(command []string) error { 96 | cArray := C.makeCharArray1(C.int(len(command) + 1)) 97 | if cArray == nil { 98 | panic("got NULL from calloc") 99 | } 100 | defer C.free(unsafe.Pointer(cArray)) 101 | 102 | for i, s := range command { 103 | cStr := C.CString(s) 104 | C.setArrayString1(cArray, C.int(i), cStr) 105 | defer C.free(unsafe.Pointer(cStr)) 106 | } 107 | 108 | return NewError(C.mpv_command(m.handle, cArray)) 109 | } 110 | 111 | func (m *Mpv) CommandNode(command []string) int { 112 | //int mpv_command_node(mpv_handle *ctx, mpv_node *args, mpv_node *result); 113 | //TODO 114 | panic("Not supported command") 115 | return -1 116 | } 117 | 118 | func (m *Mpv) CommandString(command string) error { 119 | ccmd := C.CString(command) 120 | defer C.free(unsafe.Pointer(ccmd)) 121 | return NewError(C.mpv_command_string(m.handle, ccmd)) 122 | } 123 | 124 | func (m *Mpv) CommandAsync(replyUserdata uint64, command []string) error { 125 | cArray := C.makeCharArray1(C.int(len(command) + 1)) 126 | if cArray == nil { 127 | panic("got NULL from calloc") 128 | } 129 | defer C.free(unsafe.Pointer(cArray)) 130 | 131 | for i, s := range command { 132 | cStr := C.CString(s) 133 | C.setArrayString1(cArray, C.int(i), cStr) 134 | defer C.free(unsafe.Pointer(cStr)) 135 | } 136 | 137 | return NewError(C.mpv_command_async(m.handle, C.uint64_t(replyUserdata), cArray)) 138 | } 139 | 140 | func (m *Mpv) CommandNodeAsync(command []string) int { 141 | //int mpv_command_node_async(mpv_handle *ctx, uint64_t reply_userdata, mpv_node *args); 142 | //TODO 143 | panic("Not supported command") 144 | return -1 145 | } 146 | 147 | func (m *Mpv) SetProperty(name string, format Format, data interface{}) error { 148 | cname := C.CString(name) 149 | defer C.free(unsafe.Pointer(cname)) 150 | ptr := data2Ptr(format, data) 151 | return NewError(C.mpv_set_property(m.handle, cname, C.mpv_format(format), ptr)) 152 | } 153 | 154 | func (m *Mpv) SetPropertyString(name, data string) error { 155 | cname := C.CString(name) 156 | cdata := C.CString(data) 157 | defer C.free(unsafe.Pointer(cname)) 158 | defer C.free(unsafe.Pointer(cdata)) 159 | return NewError(C.mpv_set_property_string(m.handle, cname, cdata)) 160 | } 161 | 162 | func (m *Mpv) SetPropertyAsync(name string, replyUserdata uint64, format Format, data interface{}) error { 163 | cname := C.CString(name) 164 | defer C.free(unsafe.Pointer(cname)) 165 | ptr := data2Ptr(format, data) 166 | return NewError(C.mpv_set_property_async(m.handle, C.uint64_t(replyUserdata), cname, C.mpv_format(format), ptr)) 167 | } 168 | 169 | func (m *Mpv) GetProperty(name string, format Format) (interface{}, error) { 170 | cname := C.CString(name) 171 | defer C.free(unsafe.Pointer(cname)) 172 | 173 | switch format { 174 | case FORMAT_STRING, FORMAT_OSD_STRING: 175 | { 176 | var cval *C.char 177 | err := NewError(C.mpv_get_property(m.handle, cname, C.mpv_format(format), unsafe.Pointer(&cval))) 178 | if err != nil { 179 | return nil, err 180 | } 181 | defer C.mpv_free(unsafe.Pointer(cval)) 182 | return C.GoString(cval), nil 183 | } 184 | case FORMAT_INT64: 185 | { 186 | var cval C.int64_t 187 | err := NewError(C.mpv_get_property(m.handle, cname, C.mpv_format(format), unsafe.Pointer(&cval))) 188 | if err != nil { 189 | return nil, err 190 | } 191 | return int64(cval), nil 192 | } 193 | case FORMAT_DOUBLE: 194 | { 195 | var cval C.double 196 | err := NewError(C.mpv_get_property(m.handle, cname, C.mpv_format(format), unsafe.Pointer(&cval))) 197 | if err != nil { 198 | return nil, err 199 | } 200 | return float64(cval), nil 201 | } 202 | case FORMAT_FLAG: 203 | { 204 | var cval C.int 205 | err := NewError(C.mpv_get_property(m.handle, cname, C.mpv_format(format), unsafe.Pointer(&cval))) 206 | if err != nil { 207 | return nil, err 208 | } 209 | return cval == 1, nil 210 | } 211 | case FORMAT_NONE: 212 | { 213 | err := NewError(C.mpv_get_property(m.handle, cname, C.mpv_format(format), nil)) 214 | if err != nil { 215 | return nil, err 216 | } 217 | return nil, nil 218 | } 219 | case FORMAT_NODE: 220 | { 221 | var cval C.mpv_node 222 | err := NewError(C.mpv_get_property(m.handle, cname, C.mpv_format(format), unsafe.Pointer(&cval))) 223 | if err != nil { 224 | return nil, err 225 | } 226 | return GetNode(&cval) 227 | } 228 | case FORMAT_NODE_ARRAY: 229 | { 230 | var cval C.mpv_node_list 231 | err := NewError(C.mpv_get_property(m.handle, cname, C.mpv_format(format), unsafe.Pointer(&cval))) 232 | if err != nil { 233 | return nil, err 234 | } 235 | return GetNodeList(&cval) 236 | } 237 | case FORMAT_NODE_MAP: 238 | { 239 | var cval C.mpv_node_list 240 | err := NewError(C.mpv_get_property(m.handle, cname, C.mpv_format(format), unsafe.Pointer(&cval))) 241 | if err != nil { 242 | return nil, err 243 | } 244 | return GetNodeMap(cval) 245 | } 246 | default: 247 | panic("Not supported format") 248 | } 249 | } 250 | 251 | func (m *Mpv) GetPropertyString(name string) string { 252 | cname := C.CString(name) 253 | defer C.free(unsafe.Pointer(cname)) 254 | 255 | cstr := C.mpv_get_property_string(m.handle, cname) 256 | if cstr != nil { 257 | str := C.GoString(cstr) 258 | C.mpv_free(unsafe.Pointer(cstr)) 259 | return str 260 | } 261 | 262 | return "" 263 | } 264 | 265 | func (m *Mpv) GetPropertyOsdString(name string) string { 266 | cname := C.CString(name) 267 | defer C.free(unsafe.Pointer(cname)) 268 | cstr := C.mpv_get_property_osd_string(m.handle, cname) 269 | if cstr != nil { 270 | str := C.GoString(cstr) 271 | C.mpv_free(unsafe.Pointer(cstr)) 272 | return str 273 | } 274 | 275 | return "" 276 | } 277 | 278 | func (m *Mpv) GetPropertyAsync(name string, replyUserdata uint64, format Format) error { 279 | cname := C.CString(name) 280 | defer C.free(unsafe.Pointer(cname)) 281 | 282 | return NewError(C.mpv_get_property_async(m.handle, C.uint64_t(replyUserdata), cname, C.mpv_format(format))) 283 | } 284 | 285 | func (m *Mpv) ObserveProperty(replyUserdata uint64, name string, format Format) error { 286 | cname := C.CString(name) 287 | defer C.free(unsafe.Pointer(cname)) 288 | return NewError(C.mpv_observe_property(m.handle, C.uint64_t(replyUserdata), cname, C.mpv_format(format))) 289 | } 290 | 291 | func (m *Mpv) UnObserveProperty(registeredReplyUserdata uint64) error { 292 | return NewError(C.mpv_unobserve_property(m.handle, C.uint64_t(registeredReplyUserdata))) 293 | } 294 | 295 | func (m *Mpv) RequestEvent(event EventId, enable bool) error { 296 | var en C.int = 0 297 | if enable { 298 | en = 1 299 | } 300 | return NewError(C.mpv_request_event(m.handle, C.mpv_event_id(event), en)) 301 | } 302 | 303 | func (m *Mpv) RequestLogMessages(minLevel string) error { 304 | clevel := C.CString(minLevel) 305 | defer C.free(unsafe.Pointer(clevel)) 306 | return NewError(C.mpv_request_log_messages(m.handle, clevel)) 307 | } 308 | 309 | func (m *Mpv) WaitEvent(timeout float32) *Event { 310 | var cevent *C.mpv_event 311 | cevent = C.mpv_wait_event(m.handle, C.double(timeout)) 312 | if cevent == nil { 313 | return nil 314 | } 315 | 316 | e := &Event{} 317 | 318 | e.Event_Id = EventId(cevent.event_id) 319 | e.Reply_Userdata = uint64(cevent.reply_userdata) 320 | e.Error = NewError(cevent.error) 321 | if e.Event_Id == EVENT_END_FILE { 322 | var eef *C.mpv_event_end_file = (*C.struct_mpv_event_end_file)(cevent.data) 323 | efr := EventEndFile{} 324 | efr.Reason = EndFileReason(eef.reason) 325 | efr.ErrCode = Error(eef.error) 326 | e.Data = &efr 327 | } else if e.Event_Id == EVENT_PROPERTY_CHANGE { 328 | rawData := (*C.mpv_event_property)(cevent.data) 329 | mep := EventProperty{ 330 | Name: C.GoString(rawData.name), 331 | Format: Format(rawData.format), 332 | Data: rawData.data, 333 | } 334 | e.Data = &mep 335 | } else { 336 | e.Data = cevent.data 337 | } 338 | return e 339 | } 340 | 341 | func (m *Mpv) Wakeup() { 342 | C.mpv_wakeup(m.handle) 343 | } 344 | 345 | func (m *Mpv) SetWakeupCallback(callback func(d interface{}), d interface{}) { 346 | /*callbackFunc = callback 347 | callbackVar = d*/ 348 | // C.mpv_set_wakeup_callback(m.handle,,unsafe.Pointer(d)) 349 | //TODO void mpv_set_wakeup_callback(mpv_handle *ctx, void (*cb)(void *d), void *d); 350 | panic("Not supported mpv_set_wakeup_callback") 351 | } 352 | 353 | func (m *Mpv) GetWakeupPipe() int { 354 | return int(C.mpv_get_wakeup_pipe(m.handle)) 355 | } 356 | 357 | func (m *Mpv) WaitAsyncRequests() { 358 | C.mpv_wait_async_requests(m.handle) 359 | } 360 | 361 | func (m *Mpv) GetSubApi(api SubApi) unsafe.Pointer { 362 | return unsafe.Pointer(C.mpv_get_sub_api(m.handle, C.mpv_sub_api(api))) 363 | } 364 | 365 | func data2Ptr(format Format, data interface{}) unsafe.Pointer { 366 | var ptr unsafe.Pointer = nil 367 | switch format { 368 | case FORMAT_STRING, FORMAT_OSD_STRING: 369 | { 370 | ptr = unsafe.Pointer(&[]byte(data.(string))[0]) 371 | } 372 | case FORMAT_INT64: 373 | { 374 | i, ok := data.(int64) 375 | if !ok { 376 | i = int64(data.(int)) 377 | } 378 | val := C.int64_t(i) 379 | ptr = unsafe.Pointer(&val) 380 | } 381 | case FORMAT_DOUBLE: 382 | { 383 | val := C.double(data.(float64)) 384 | ptr = unsafe.Pointer(&val) 385 | } 386 | case FORMAT_FLAG: 387 | { 388 | val := C.int(0) 389 | if data.(bool) { 390 | val = 1 391 | } 392 | ptr = unsafe.Pointer(&val) 393 | } 394 | case FORMAT_NONE: 395 | { 396 | return nil 397 | } 398 | 399 | case FORMAT_NODE: 400 | { 401 | val := (data.(*Node)) 402 | cnode := val.GetCNode() 403 | ptr = unsafe.Pointer(cnode) 404 | } 405 | 406 | case FORMAT_NODE_ARRAY, FORMAT_NODE_MAP: 407 | { 408 | return nil 409 | } 410 | } 411 | return ptr 412 | } 413 | 414 | type Event struct { 415 | Event_Id EventId 416 | Error error 417 | Reply_Userdata uint64 418 | Data interface{} 419 | } 420 | 421 | type EventProperty struct { 422 | Name string 423 | Format Format 424 | Data interface{} 425 | } 426 | 427 | type EventEndFile struct { 428 | Reason EndFileReason 429 | ErrCode Error 430 | } 431 | -------------------------------------------------------------------------------- /player/mpv/node.go: -------------------------------------------------------------------------------- 1 | package mpv 2 | 3 | /* 4 | #include 5 | 6 | struct mpv_node* GetNodeFromList(struct mpv_node* list,int i){ 7 | return &list[i]; 8 | } 9 | 10 | char* GetString(char** strings,int i){ 11 | return strings[i]; 12 | } 13 | 14 | */ 15 | import "C" 16 | import ( 17 | "bytes" 18 | "encoding/binary" 19 | "log" 20 | "unsafe" 21 | ) 22 | 23 | type Node struct { 24 | value interface{} 25 | format Format 26 | } 27 | 28 | func NewNode(value interface{}, format Format) *Node { 29 | n := &Node{} 30 | n.value = value 31 | n.format = format 32 | return n 33 | } 34 | 35 | func (n *Node) GetVal() interface{} { 36 | return n.value 37 | } 38 | 39 | func (n *Node) GetCNode() *C.mpv_node { 40 | ptr := data2Ptr(n.format, n.value) 41 | if ptr == nil { 42 | return nil 43 | } 44 | writer := new(bytes.Buffer) 45 | err := binary.Write(writer, binary.LittleEndian, uint64(uintptr(ptr))) 46 | if err != nil { 47 | log.Println("Error write bin", err) 48 | return nil 49 | } 50 | buf := writer.Bytes() 51 | node := &C.mpv_node{} 52 | for i := 0; i < len(buf) && i < 8; i++ { 53 | node.u[i] = buf[i] 54 | } 55 | node.format = C.mpv_format(n.format) 56 | return node 57 | } 58 | 59 | func GetNode(node *C.mpv_node) (*Node, error) { 60 | n := &Node{} 61 | var err error 62 | n.value, err = GetValue(node) 63 | n.format = Format(node.format) 64 | return n, err 65 | } 66 | 67 | func FreeMpvNode(cnode *C.mpv_node) { 68 | C.mpv_free_node_contents(cnode) 69 | } 70 | 71 | func GetValue(node *C.mpv_node) (interface{}, error) { 72 | format := Format(node.format) 73 | buf := bytes.NewReader(C.GoBytes(unsafe.Pointer(&node.u[0]), 8)) 74 | var ptr uint64 75 | err := binary.Read(buf, binary.LittleEndian, &ptr) 76 | if err != nil { 77 | log.Println("Error binary read", err) 78 | return nil, err 79 | } 80 | switch format { 81 | case FORMAT_STRING: 82 | { 83 | var ret *C.char 84 | ret = (*C.char)((unsafe.Pointer)(uintptr(ptr))) 85 | return C.GoString(ret), nil 86 | } 87 | case FORMAT_FLAG: 88 | { 89 | var ret bool 90 | ret = *(*C.int)((unsafe.Pointer)(uintptr(ptr))) != 0 91 | return ret, nil 92 | } 93 | case FORMAT_INT64: 94 | { 95 | var ret C.int64_t 96 | ret = *(*C.int64_t)((unsafe.Pointer)(uintptr(ptr))) 97 | return int64(ret), nil 98 | } 99 | case FORMAT_DOUBLE: 100 | { 101 | var ret C.double 102 | ret = *(*C.double)((unsafe.Pointer)(uintptr(ptr))) 103 | return float64(ret), nil 104 | } 105 | case FORMAT_NODE: 106 | { 107 | var ret C.mpv_node 108 | ret = *(*C.mpv_node)((unsafe.Pointer)(uintptr(ptr))) 109 | return GetNode(&ret) 110 | } 111 | case FORMAT_NODE_ARRAY: 112 | { 113 | var ret C.mpv_node_list 114 | ret = *(*C.mpv_node_list)((unsafe.Pointer)(uintptr(ptr))) 115 | return GetNodeList(&ret) 116 | } 117 | case FORMAT_NODE_MAP: 118 | { 119 | var ret C.mpv_node_list 120 | ret = *(*C.mpv_node_list)((unsafe.Pointer)(uintptr(ptr))) 121 | return GetNodeMap(ret) 122 | } 123 | case FORMAT_BYTE_ARRAY: 124 | { 125 | var ret C.mpv_byte_array 126 | ret = *(*C.mpv_byte_array)((unsafe.Pointer)(uintptr(ptr))) 127 | return C.GoBytes(ret.data, C.int(ret.size)), nil 128 | } 129 | default: 130 | { 131 | return nil, nil 132 | } 133 | } 134 | } 135 | 136 | func GetNodeList(clist *C.mpv_node_list) ([]*Node, error) { 137 | nodes := make([]*Node, clist.num) 138 | var err error 139 | var n C.int 140 | for n = 0; n < clist.num; n++ { 141 | nodes[n], err = GetNode(C.GetNodeFromList(clist.values, n)) 142 | if err != nil { 143 | return nil, err 144 | } 145 | } 146 | return nodes, nil 147 | } 148 | 149 | func GetCNodeList(list []*Node) *C.mpv_node_list { 150 | /*if len(list) <= 0 { 151 | return nil 152 | } 153 | cnlist := &C.mpv_node_list{} 154 | cnlist.num = C.int(len(list)) 155 | carr := unsafe.NewArray(C.mpv_node, len(list)) 156 | for i, n := range list { 157 | carr[i] = *n.GetCNode() 158 | }*/ 159 | return nil 160 | } 161 | 162 | func GetNodeMap(cmap C.mpv_node_list) (map[string]*Node, error) { 163 | nodes, err := GetNodeList(&cmap) 164 | if err != nil { 165 | return nil, err 166 | } 167 | var mapnode map[string]*Node 168 | for i, n := range nodes { 169 | mapnode[C.GoString(C.GetString(cmap.keys, C.int(i)))] = n 170 | } 171 | return mapnode, nil 172 | } 173 | -------------------------------------------------------------------------------- /player/mpv/node_test.go: -------------------------------------------------------------------------------- 1 | package mpv 2 | 3 | import ( 4 | "log" 5 | "testing" 6 | ) 7 | 8 | func TestNode(t *testing.T) { 9 | { 10 | val := "123456789" 11 | format := FORMAT_STRING 12 | node := NewNode(val, format) 13 | cnode := node.GetCNode() 14 | node, err := GetNode(cnode) 15 | log.Println("CNode:", cnode, "Node val:", node.GetVal().(string), "Error:", err) 16 | if cnode == nil { 17 | log.Fatal("Fail node convert string") 18 | } 19 | } 20 | { 21 | val := int64(0x7FFFFFFFFFFFFFFF) 22 | format := FORMAT_INT64 23 | node := NewNode(val, format) 24 | cnode := node.GetCNode() 25 | node, err := GetNode(cnode) 26 | log.Println("CNode:", cnode, "Node val:", node.GetVal(), "Error:", err) 27 | if cnode == nil { 28 | log.Fatal("Fail node convert int64") 29 | } 30 | } 31 | { 32 | val := bool(true) 33 | format := FORMAT_FLAG 34 | node := NewNode(val, format) 35 | cnode := node.GetCNode() 36 | node, err := GetNode(cnode) 37 | log.Println("CNode:", cnode, "Node val:", node.GetVal(), "Error:", err) 38 | if cnode == nil { 39 | log.Fatal("Fail node convert int64") 40 | } 41 | } 42 | 43 | { 44 | val := float64(1.7976931348623157E+308) 45 | format := FORMAT_DOUBLE 46 | node := NewNode(val, format) 47 | cnode := node.GetCNode() 48 | node, err := GetNode(cnode) 49 | log.Println("CNode:", cnode, "Node val:", node.GetVal(), "Error:", err) 50 | if cnode == nil { 51 | log.Fatal("Fail node convert int64") 52 | } 53 | } 54 | 55 | { 56 | val := NewNode(123, FORMAT_INT64) 57 | format := FORMAT_NODE 58 | node := NewNode(val, format) 59 | cnode := node.GetCNode() 60 | node, err := GetNode(cnode) 61 | log.Println("CNode:", cnode, "Node val:", node.GetVal(), "Error:", err) 62 | if cnode == nil { 63 | log.Fatal("Fail node convert int64") 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /player/state.go: -------------------------------------------------------------------------------- 1 | package player 2 | 3 | import ( 4 | "github.com/Pauloo27/neptune/db" 5 | "github.com/Pauloo27/neptune/providers/youtube" 6 | ) 7 | 8 | type PlayerState struct { 9 | Paused bool 10 | Fetching *youtube.YoutubeEntry 11 | Queue []*db.Track 12 | QueueIndex int 13 | Volume float64 14 | Duration float64 15 | loopFile, loopPlaylist bool 16 | } 17 | -------------------------------------------------------------------------------- /providers/track_info.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/url" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/Pauloo27/neptune/providers/youtube" 12 | "github.com/Pauloo27/neptune/utils" 13 | "github.com/buger/jsonparser" 14 | ) 15 | 16 | type ArtistInfo struct { 17 | Name, MBID string 18 | } 19 | 20 | type AlbumInfo struct { 21 | Title, MBID, ImageURL string 22 | } 23 | 24 | type TrackInfo struct { 25 | Title, MBID string 26 | Tags []string 27 | Artist *ArtistInfo 28 | Album *AlbumInfo 29 | } 30 | 31 | const ( 32 | API_KEY = "12dec50313f885d407cf8132697b8712" 33 | ENDPOINT = "https://ws.audioscrobbler.com/2.0" 34 | ) 35 | 36 | var parenthesisRegex = regexp.MustCompile(`\s?\(.+\)`) 37 | 38 | func FetchTrackInfo(info *youtube.VideoInfo) (*TrackInfo, error) { 39 | fmt.Printf("Fetching track info for %s by %s\n", info.Track, info.Artist) 40 | 41 | // fix track with '(stuff)' 42 | trackName := parenthesisRegex.ReplaceAllString(info.Track, "") 43 | // fix for "artist" list (splitted by ',') 44 | artist := strings.Split(info.Artist, ",")[0] 45 | 46 | // escape params 47 | escapedArtist := url.QueryEscape(artist) 48 | escapedTrack := url.QueryEscape(trackName) 49 | 50 | reqPath := utils.Fmt( 51 | "%s/?method=track.getInfo&api_key=%s&artist=%s&track=%s&format=json", 52 | ENDPOINT, API_KEY, escapedArtist, escapedTrack, 53 | ) 54 | 55 | res, err := http.Get(reqPath) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | defer res.Body.Close() 61 | 62 | buffer, err := ioutil.ReadAll(res.Body) 63 | 64 | // artist info 65 | artistName, err := jsonparser.GetString(buffer, "track", "artist", "name") 66 | if err != nil { 67 | artistName = artist 68 | } 69 | 70 | artistMBID, err := jsonparser.GetString(buffer, "track", "artist", "mbid") 71 | if err != nil { 72 | artistMBID = "*" + artistName 73 | } 74 | 75 | // album info 76 | albumTitle, err := jsonparser.GetString(buffer, "track", "album", "title") 77 | if err != nil { 78 | albumTitle = utils.Fmt("Unknown %s's album", artistName) 79 | } 80 | 81 | albumMBID, err := jsonparser.GetString(buffer, "track", "album", "mbid") 82 | if err != nil { 83 | albumMBID = artistMBID + "|" + albumTitle 84 | } 85 | 86 | albumImageURL, err := jsonparser.GetString(buffer, "track", "album", "image", "[3]", "#text") 87 | if err != nil { 88 | albumImageURL = info.GetThumbnail() 89 | } 90 | 91 | // track info 92 | trackTitle, err := jsonparser.GetString(buffer, "track", "name") 93 | if err != nil { 94 | trackTitle = info.Track 95 | } 96 | 97 | trackMBID, err := jsonparser.GetString(buffer, "track", "mbid") 98 | if err != nil { 99 | trackMBID = albumMBID + "|" + trackTitle 100 | } 101 | 102 | var trackTags []string 103 | tagsArr, _, _, err := jsonparser.Get(buffer, "track", "toptags", "tag") 104 | 105 | _, err = jsonparser.ArrayEach(tagsArr, func(data []byte, t jsonparser.ValueType, i int, err error) { 106 | tagName, err := jsonparser.GetString(data, "name") 107 | trackTags = append(trackTags, tagName) 108 | }) 109 | 110 | return &TrackInfo{ 111 | Title: trackTitle, 112 | MBID: trackMBID, 113 | Tags: trackTags, 114 | Album: &AlbumInfo{ 115 | Title: albumTitle, 116 | MBID: albumMBID, 117 | ImageURL: albumImageURL, 118 | }, 119 | Artist: &ArtistInfo{ 120 | Name: artistName, 121 | MBID: artistMBID, 122 | }, 123 | }, nil 124 | } 125 | -------------------------------------------------------------------------------- /providers/youtube/search.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/buger/jsonparser" 11 | ) 12 | 13 | type YoutubeEntry struct { 14 | Title, Uploader, DisplayDuration, ID string 15 | Live bool 16 | } 17 | 18 | func (result *YoutubeEntry) URL() string { 19 | return fmt.Sprintf("https://youtube.com/watch?v=%s", result.ID) 20 | } 21 | 22 | func getContent(data []byte, index int) []byte { 23 | id := fmt.Sprintf("[%d]", index) 24 | contents, _, _, _ := jsonparser.Get(data, "contents", "twoColumnSearchResultsRenderer", "primaryContents", "sectionListRenderer", "contents", id, "itemSectionRenderer", "contents") 25 | return contents 26 | } 27 | 28 | func SearchFor(searchTerm string, limit int) ([]*YoutubeEntry, error) { 29 | url := fmt.Sprintf("https://www.youtube.com/results?search_query=%s", url.QueryEscape(searchTerm)) 30 | 31 | client := &http.Client{} 32 | req, err := http.NewRequest("GET", url, nil) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | req.Header.Add("Accept-Language", "en") 38 | res, err := client.Do(req) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | defer res.Body.Close() 44 | if res.StatusCode != 200 { 45 | return nil, fmt.Errorf("Status code %d, 200 expected", res.StatusCode) 46 | } 47 | 48 | buffer, err := ioutil.ReadAll(res.Body) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | body := string(buffer) 54 | splittedScript := strings.Split(body, `window["ytInitialData"] = `) 55 | if len(splittedScript) != 2 { 56 | splittedScript = strings.Split(body, `var ytInitialData = `) 57 | } 58 | 59 | if len(splittedScript) != 2 { 60 | return nil, fmt.Errorf("Too much splitted scripts") 61 | } 62 | splittedScript = strings.Split(splittedScript[1], `window["ytInitialPlayerResponse"] = null;`) 63 | jsonData := []byte(splittedScript[0]) 64 | 65 | index := 0 66 | var contents []byte 67 | 68 | for { 69 | contents = getContent(jsonData, index) 70 | _, _, _, err = jsonparser.Get(contents, "[0]", "carouselAdRenderer") 71 | 72 | if err == nil { 73 | index++ 74 | } else { 75 | break 76 | } 77 | } 78 | 79 | results := []*YoutubeEntry{} 80 | 81 | _, err = jsonparser.ArrayEach(contents, func(value []byte, t jsonparser.ValueType, i int, err error) { 82 | if limit > 0 && len(results) >= limit { 83 | return 84 | } 85 | 86 | id, err := jsonparser.GetString(value, "videoRenderer", "videoId") 87 | if err != nil { 88 | return 89 | } 90 | 91 | title, err := jsonparser.GetString(value, "videoRenderer", "title", "runs", "[0]", "text") 92 | if err != nil { 93 | return 94 | } 95 | 96 | uploader, err := jsonparser.GetString(value, "videoRenderer", "ownerText", "runs", "[0]", "text") 97 | if err != nil { 98 | return 99 | } 100 | 101 | live := false 102 | duration, err := jsonparser.GetString(value, "videoRenderer", "lengthText", "simpleText") 103 | 104 | if err != nil { 105 | duration = "" 106 | live = true 107 | } 108 | 109 | results = append(results, &YoutubeEntry{ 110 | Title: title, 111 | Uploader: uploader, 112 | DisplayDuration: duration, 113 | ID: id, 114 | Live: live, 115 | }) 116 | }) 117 | 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | return results, nil 123 | } 124 | -------------------------------------------------------------------------------- /providers/youtube/youtube_dl.go: -------------------------------------------------------------------------------- 1 | package youtube 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "runtime" 7 | 8 | "github.com/Pauloo27/neptune/utils" 9 | "github.com/buger/jsonparser" 10 | ) 11 | 12 | type VideoInfo struct { 13 | Artist, Track, Uploader, UploaderID, Title, ID string 14 | Duration int64 15 | } 16 | 17 | func (v *VideoInfo) GetThumbnail() string { 18 | return utils.Fmt( 19 | "https://i1.ytimg.com/vi/%s/hqdefault.jpg", v.ID, 20 | ) 21 | } 22 | 23 | func parseVideoInfo(buffer []byte) *VideoInfo { 24 | uploader, _ := jsonparser.GetString(buffer, "uploader") 25 | uploaderID, _ := jsonparser.GetString(buffer, "uploader_id") 26 | title, _ := jsonparser.GetString(buffer, "title") 27 | id, _ := jsonparser.GetString(buffer, "id") 28 | duration, _ := jsonparser.GetInt(buffer, "duration") 29 | artist, _ := jsonparser.GetString(buffer, "artist") 30 | track, _ := jsonparser.GetString(buffer, "track") 31 | 32 | return &VideoInfo{artist, track, uploader, uploaderID, title, id, duration} 33 | } 34 | 35 | var youtubeDLPath string 36 | 37 | func GetYouTubeDLPath() string { 38 | if youtubeDLPath != "" { 39 | return youtubeDLPath 40 | } 41 | if runtime.GOOS == "windows" { 42 | youtubeDLPath = "bin/youtube-dl.exe" 43 | } else { 44 | youtubeDLPath = "youtube-dl" 45 | } 46 | return youtubeDLPath 47 | } 48 | 49 | func FetchInfoAndDownload(result *YoutubeEntry, filePath string) (*VideoInfo, error) { 50 | cmd := exec.Command(GetYouTubeDLPath(), result.URL(), 51 | "-f 140", "--add-metadata", "-o", filePath, "--print-json", 52 | ) 53 | 54 | buffer, err := cmd.Output() 55 | if err != nil { 56 | if runtime.GOOS != "windows" { 57 | return nil, err 58 | } 59 | if stat, err := os.Stat(filePath); err != nil || stat.Size() <= 1 { 60 | return nil, err 61 | } 62 | } 63 | 64 | return parseVideoInfo(buffer), nil 65 | } 66 | 67 | func FetchInfo(result *YoutubeEntry) (*VideoInfo, error) { 68 | cmd := exec.Command(GetYouTubeDLPath(), result.URL(), "--dump-json") 69 | 70 | buffer, err := cmd.Output() 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return parseVideoInfo(buffer), nil 76 | } 77 | -------------------------------------------------------------------------------- /trayicon/icon/icon.go: -------------------------------------------------------------------------------- 1 | package icon 2 | 3 | import _ "embed" 4 | 5 | //go:embed "icon.png" 6 | var ICON_DATA []byte 7 | -------------------------------------------------------------------------------- /trayicon/icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pauloo27/neptune/524f80dca13a93fc31a4a9b1d5b03b681e80a495/trayicon/icon/icon.png -------------------------------------------------------------------------------- /trayicon/tray.go: -------------------------------------------------------------------------------- 1 | package trayicon 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "github.com/Pauloo27/neptune/hook" 8 | "github.com/Pauloo27/neptune/trayicon/icon" 9 | "github.com/getlantern/systray" 10 | ) 11 | 12 | func LoadTrayIcon() { 13 | if runtime.GOOS == "windows" { 14 | return 15 | } 16 | systray.Run(onReady, onExit) 17 | } 18 | 19 | func onExit() { 20 | } 21 | 22 | func onReady() { 23 | fmt.Println("Ready") 24 | systray.SetTitle("Neptune") 25 | systray.SetTooltip("Neptune") 26 | systray.SetIcon(icon.ICON_DATA) 27 | 28 | mShowHide := systray.AddMenuItem("Show/Hide player", "Show/Hide player") 29 | mQuit := systray.AddMenuItem("Quit", "Quit") 30 | 31 | go func() { 32 | for { 33 | select { 34 | case <-mQuit.ClickedCh: 35 | hook.CallHooks(hook.HOOK_REQUEST_EXIT) 36 | case <-mShowHide.ClickedCh: 37 | hook.CallHooks(hook.HOOK_REQUEST_SHOW_HIDE) 38 | } 39 | } 40 | }() 41 | } 42 | -------------------------------------------------------------------------------- /utils/fmt.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | func Fmt(format string, values ...interface{}) string { 9 | return fmt.Sprintf(format, values...) 10 | } 11 | 12 | func EnforceSize(text string, maxLen int) string { 13 | if maxLen == 0 || len(text) <= maxLen { 14 | return text 15 | } 16 | 17 | return text[0:maxLen-3] + "..." 18 | } 19 | 20 | func Pad(n int, minSize int) string { 21 | str := strconv.Itoa(n) 22 | for len(str) < minSize { 23 | str = "0" + str 24 | } 25 | return str 26 | } 27 | 28 | func FormatDuration(durationInSeconds float64) string { 29 | minutes := int(durationInSeconds / 60.0) 30 | seconds := int(durationInSeconds) % 60 31 | return Fmt("%s:%s", Pad(minutes, 2), Pad(seconds, 2)) 32 | } 33 | -------------------------------------------------------------------------------- /utils/fs.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | "path" 8 | ) 9 | 10 | var userHome string 11 | 12 | func GetUserHome() string { 13 | if userHome == "" { 14 | var err error 15 | userHome, err = os.UserHomeDir() 16 | HandleError(err, "Cannot get user home") 17 | } 18 | return userHome 19 | } 20 | 21 | var tmpFolder string 22 | 23 | func GetTmpFolder() string { 24 | if tmpFolder == "" { 25 | tmpFolder = path.Join(GetUserHome(), ".cache", "neptune", "tmp") 26 | err := os.MkdirAll(tmpFolder, 0744) 27 | HandleError(err, "Cannot create tmp folder "+tmpFolder) 28 | } 29 | return tmpFolder 30 | } 31 | 32 | func DownloadFile(fileURL, targetFilePath string) error { 33 | res, err := http.Get(fileURL) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | defer res.Body.Close() 39 | 40 | file, err := os.Create(targetFilePath) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | defer file.Close() 46 | 47 | _, err = io.Copy(file, res.Body) 48 | return err 49 | } 50 | -------------------------------------------------------------------------------- /utils/logger.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "log" 5 | ) 6 | 7 | const ( 8 | ColorBold = "\033[1m" 9 | ColorReset = "\033[0m" 10 | ColorRed = "\033[31m" 11 | ColorGreen = "\033[32m" 12 | ColorYellow = "\033[33m" 13 | ColorBlue = "\033[34m" 14 | ColorWhite = "\033[39m" 15 | ) 16 | 17 | func HandleError(err error, message string) { 18 | if err != nil { 19 | log.Panicf("%s:\n%v", message, err) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /version/migrate.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import "fmt" 4 | 5 | func MigrateFrom(prevVersion string) bool { 6 | if prevVersion != VERSION { 7 | fmt.Printf("Updated from version %s to %s\n", prevVersion, VERSION) 8 | } 9 | return false 10 | } 11 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | const ( 4 | VERSION = "v0.0.1-dev" 5 | ) 6 | --------------------------------------------------------------------------------