├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── analysis_options.yaml ├── assets └── Thumbnail.png ├── bin └── main.dart ├── lib ├── Spogit.dart ├── cache │ ├── album │ │ ├── album_resource.dart │ │ └── album_resource_manager.dart │ ├── cache_manager.dart │ ├── cache_types.dart │ ├── cached_resource.dart │ ├── cover_resource.dart │ └── id │ │ ├── id_resource.dart │ │ └── id_resource_manager.dart ├── change_watcher.dart ├── driver │ ├── driver_api.dart │ ├── driver_request.dart │ ├── js_communication.dart │ └── playlist_manager.dart ├── driver_utility.dart ├── fs │ ├── local_storage.dart │ └── playlist.dart ├── git_hook.dart ├── input_controller.dart ├── json │ ├── album_full.dart │ ├── album_simplified.dart │ ├── artist.dart │ ├── image.dart │ ├── json.dart │ ├── paging.dart │ ├── playlist_full.dart │ ├── playlist_simplified.dart │ ├── sub │ │ ├── external_ids.dart │ │ └── external_url.dart │ ├── track_full.dart │ └── track_simplified.dart ├── local_manager.dart ├── markdown │ ├── md_generator.dart │ ├── readme.dart │ └── table_generator.dart ├── setup.dart ├── url_browser.dart └── utility.dart └── pubspec.yaml /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: v* 6 | 7 | jobs: 8 | create-release: 9 | name: Create release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Create Release 13 | id: create_release 14 | uses: actions/create-release@v1 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | with: 18 | tag_name: ${{ github.ref }} 19 | release_name: Release ${{ github.ref }} 20 | draft: false 21 | prerelease: false 22 | - run: echo "${{ steps.create_release.outputs.upload_url }}" > upload_url 23 | - name: Saving upload URL 24 | uses: actions/upload-artifact@v1 25 | with: 26 | name: upload_url 27 | path: upload_url 28 | build: 29 | name: Build Matrix 30 | runs-on: ${{ matrix.os }} 31 | strategy: 32 | matrix: 33 | include: 34 | - os: ubuntu-latest 35 | filename: Spogit-Ubuntu 36 | - os: ubuntu-latest 37 | filename: Spogit-AOT 38 | extra: -k aot 39 | - os: windows-latest 40 | filename: Spogit-Windows.exe 41 | - os: macos-latest 42 | filename: Spogit-MacOS 43 | steps: 44 | - uses: actions/checkout@v2 45 | - uses: cedx/setup-dart@v2 46 | - run: echo ${{ matrix.os }} 47 | - name: Install Dependencies 48 | run: pub get 49 | - name: Compile Dart 50 | run: dart2native bin/main.dart -o ${{ matrix.filename }} ${{ matrix.extra }} 51 | - name: Saving upload URL 52 | uses: actions/upload-artifact@v1 53 | with: 54 | name: ${{ matrix.filename }} 55 | path: ${{ matrix.filename }} 56 | - name: Retrieving upload URL 57 | uses: actions/download-artifact@v1 58 | with: 59 | name: upload_url 60 | - name: Set the URL 61 | id: upload_url 62 | run: echo "::set-output name=upload_url::$(cat upload_url/upload_url)" 63 | - name: Upload Release Asset 64 | id: upload-release-asset 65 | uses: actions/upload-release-asset@v1 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | with: 69 | upload_url: ${{ steps.upload_url.outputs.upload_url }} 70 | asset_path: ./${{ matrix.filename }} 71 | asset_name: ${{ matrix.filename }} 72 | asset_content_type: application/octet-stream 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files and directories created by pub 2 | .dart_tool/ 3 | .packages 4 | # Remove the following pattern if you wish to check in your lock file 5 | pubspec.lock 6 | 7 | # Conventional directory for build outputs 8 | build/ 9 | 10 | # Directory created by dartdoc 11 | doc/api/ 12 | 13 | .idea 14 | *.iml 15 | *.log 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.0 2 | 3 | - Initial version, created by Stagehand 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Adam Yarris 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Release](https://github.com/RubbaBoy/Spogit/workflows/Release/badge.svg) 2 | 3 | ## Spogit 4 | 5 | Spogit is Spotify playlists (and folders of playlists) over Git. Yeah this is probably stupidly inefficient, but whatever. This will allow for pushing playlists to Git servers such as GitHub, making forks and PRs to other people's playlists. 6 | 7 | The Spotify API does not expose folders in any way, shape, or form to the web API. Spotify also removed the desktop app API, and having each user make their own dev app is yucky. Spogit had to get creative in how it uses the API, by opening a chrome browser and has you log into Spotify, using the internal Web API with that token. 8 | 9 | An example of a repository generated from a Spotify folder of playlists: [Alt-Rock](https://github.com/RubbaBoy/Alt-Rock) 10 | 11 | ## Installing 12 | 13 | Installing Spogit is very straightforward, the only requirements is that you have Google Chrome installed. To install: 14 | 15 | - Download the correct version of [ChromeDriver](https://chromedriver.chromium.org/) for your system 16 | - Download your OS's release of [Spogit](https://github.com/RubbaBoy/Spogit/releases) 17 | - Run the Spogit executable through your terminal, and login to Spotify 18 | 19 | If the program stops for whatever reason, running the executable is the only thing you need to do to get it started again. 20 | 21 | If you're illiterate and/or want to see Spogit running on a clean machine, check out the YouTube Demo: 22 | 23 | [![Thumbnail](assets/Thumbnail.png)](https://www.youtube.com/watch?v=eIRy5j_zlPA) 24 | 25 | ## Commands 26 | 27 | Commands are executed in the currently running Spogit process. The help is available by typing `help` and pressing enter, giving a result like: 28 | 29 | ``` 30 | === Command help === 31 | 32 | status 33 | Lists the linked repos and playlists 34 | 35 | list 36 | Lists your Spotify accounts' playlist and folder names and IDs. 37 | 38 | add-remote "My Demo" spotify:playlist:41fMgMIEZJLJjJ9xbzYar6 27345c6f477d000 39 | Adds a list of playlist or folder IDs to the local Spogit root with the given name. 40 | 41 | add-local "My Demo" 42 | Adds a local directory in the Spogit root to your Spotify account and begin tracking. Useful if git hooks are not working. 43 | 44 | === 45 | ``` 46 | 47 | The commands are outlined below 48 | 49 | ### Status 50 | 51 | The command `status` accepts no arguments are simply displays the status of your linked playlists. The top line of each linked group is the path of the data (e.g. `Spogit/Name`) and below are the playlist/folder names and their parsed IDs. 52 | 53 | An example: 54 | 55 | ``` 56 | Spogit/AlternateRock: 57 | Alt ∕ Rock #64896a09c264a0f1 58 | Alt #41fMgMIEZJLJjJ9xbzYar6 59 | Alternative #6Xj0tPxwbI0GEUCFMduycy 60 | Ghost #2le4cCM38wjlQUuLODY6OC 61 | Non-English #1k63hUp3qMM8Cner9wrTDK 62 | not rap #59bqg4vhhXAxV3GImhCFra 63 | Rock #668IKn6D7BT0FyQj7N7Xsr 64 | The New Alt #37i9dQZF1DX82GYcclJ3Ug 65 | ``` 66 | 67 | 68 | 69 | ### List 70 | 71 | The `list` command accepts no arguments and lists all the Spotify playlists and folders, along with their respective parsed IDs. An example of this is: 72 | 73 | ``` 74 | Listing of all current Spotify tree data. 75 | Key: 76 | P - Playlist. ID starts with spotify:playlist 77 | S - Group start. ID starts with spotify:start-group 78 | E - Group end. ID starts with spotify:end-group 79 | 80 | [S] General #20c77c3ea882ff4b 81 | [P] Speakers #4T8gh2JVgZoiGFutx04ErJ 82 | [P] Bass #6tiHNh6HjiuBFFW6YK1nqY 83 | [P] Stuff #1eEtiJrfTlaHZlkWseVU0c 84 | [E] General #20c77c3ea882ff4b 85 | [S] Alt/Rock #64896a09c264a0f1 86 | [P] Ghost #2le4cCM38wjlQUuLODY6OC 87 | [P] Non-English #1k63hUp3qMM8Cner9wrTDK 88 | [P] Alt #41fMgMIEZJLJjJ9xbzYar6 89 | [P] Rock #668IKn6D7BT0FyQj7N7Xsr 90 | [P] Alternative #6Xj0tPxwbI0GEUCFMduycy 91 | [P] The New Alt #37i9dQZF1DX82GYcclJ3Ug 92 | [P] not rap #59bqg4vhhXAxV3GImhCFra 93 | [E] Alt/Rock #64896a09c264a0f1 94 | ``` 95 | 96 | 97 | 98 | ### Adding A Remote 99 | 100 | The `add-remote` command clones your remote playlists/folders from Spotify to your local drive, allowing for them to be placed into a git repository. This command accepts an argument of the grouping name, and then a space-separated list of [Spotify ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids)s. As it may be troublesome to get playlist IDs and even harder to get folder IDs, the `list` command above is very useful. 101 | 102 | An example command adding the IDs `spotify:playlist:41fMgMIEZJLJjJ9xbzYar6` and `27345c6f477d000` (A parsed and unparsed ID example, either will work) with the grouping name `My Demo`: 103 | 104 | ``` 105 | add-remote "My Demo" spotify:playlist:41fMgMIEZJLJjJ9xbzYar6 27345c6f477d000 106 | ``` 107 | 108 | After doing this, any modification to the playlists over Spotify will modify the local copy as well, as long as the program is running. 109 | 110 | ### Adding A Local Repo 111 | 112 | If your Git hooks are not functioning properly and a repo is not auto-added when it is clones, or it is created/clones when Spogit is not running, `add-local` may be used. It accepts a single argument of the directory name in the Spogit root to add to your account from and start tracking. 113 | 114 | An example command adding the `~/Spogit/Music` cloned playlist to your Spotify account: 115 | 116 | ``` 117 | add-local "Music" 118 | ``` 119 | 120 | 121 | 122 | ## Cloning From A Git Repository 123 | 124 | Cloning a playlist from a remote repo into your account is simple. After the program is running, `cd` into your `~/Spogit` directory. Then, simple `git clone` the repo as normal. This action will automatically notify Spogit and push the data to your Spotify account. 125 | 126 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # Defines a default set of lint rules enforced for 2 | # projects at Google. For details and rationale, 3 | # see https://github.com/dart-lang/pedantic#enabled-lints. 4 | include: package:pedantic/analysis_options.yaml 5 | 6 | # For lint rules and documentation, see http://dart-lang.github.io/linter/lints. 7 | # Uncomment to specify additional rules. 8 | # linter: 9 | # rules: 10 | # - camel_case_types 11 | 12 | analyzer: 13 | # exclude: 14 | # - path/to/excluded/files/** 15 | -------------------------------------------------------------------------------- /assets/Thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RubbaBoy/Spogit/2b74aaa5a8909335c7fb9d0f11ea2923c211ecf4/assets/Thumbnail.png -------------------------------------------------------------------------------- /bin/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:Spogit/Spogit.dart'; 4 | import 'package:Spogit/utility.dart'; 5 | import 'package:args/args.dart'; 6 | import 'package:intl/intl.dart'; 7 | import 'package:logging/logging.dart'; 8 | 9 | final log = Logger('Main'); 10 | 11 | Future main(List args) async { 12 | setupLogging(); 13 | 14 | var parser = ArgParser(); 15 | parser.addFlag('help', abbr: 'h', help: 'Shows help'); 16 | parser.addOption('path', 17 | abbr: 'p', 18 | defaultsTo: '~/Spogit', 19 | help: 'The path to store and listen to Spotify files'); 20 | parser.addOption('cookies', 21 | abbr: 'c', 22 | defaultsTo: 'cookies.json', 23 | help: 'The location your Spotify cookies will be generated in, relative to the --path'); 24 | parser.addOption('chromedriver', 25 | abbr: 'd', 26 | defaultsTo: 'chromedriver${Platform.isWindows ? '.exe' : ''}', 27 | help: 'Specify the location of your chromedriver executable, relative to the --path'); 28 | parser.addOption('treeDuration', 29 | abbr: 't', 30 | defaultsTo: '4', 31 | help: 'Interval in seconds to check the Spotify tree'); 32 | parser.addOption('playlistDuration', 33 | abbr: 'l', 34 | defaultsTo: '4', 35 | help: 'Interval in seconds to check for playlist modification'); 36 | 37 | var parsed = parser.parse(args); 38 | var access = ArgAccess(parsed); 39 | 40 | if (parsed['help']) { 41 | print('Note: For all paths, you may use ~/ for your home directory.\n'); 42 | print(parser.usage); 43 | return; 44 | } 45 | 46 | var path = access['path'].directory; 47 | var cookies = [path, access['cookies']].fileRaw; 48 | var chromedriver = [path, access['chromedriver']].fileRaw; 49 | 50 | if (!path.existsSync()) { 51 | log.info('Path does not exist!'); 52 | return; 53 | } 54 | 55 | final spogit = await Spogit.createSpogit( 56 | path, cookies, chromedriver, [path, 'cache'].fileRaw, 57 | treeDuration: access['treeDuration'].parseInt(), 58 | playlistDuration: access['playlistDuration'].parseInt()); 59 | await spogit.start(); 60 | } 61 | 62 | void setupLogging() { 63 | Logger.root.level = Level.ALL; // defaults to Level.INFO 64 | final jms = DateFormat.jms(); 65 | Logger.root.onRecord.listen((record) => print( 66 | '[${jms.format(record.time)}] [${record.level.name}/${record.loggerName}]: ${record.message}')); 67 | } 68 | 69 | class ArgAccess { 70 | final ArgResults _results; 71 | 72 | ArgAccess(this._results); 73 | 74 | T operator [](String key) => _results[key] as T; 75 | } 76 | -------------------------------------------------------------------------------- /lib/Spogit.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:Spogit/cache/album/album_resource_manager.dart'; 5 | import 'package:Spogit/cache/cache_manager.dart'; 6 | import 'package:Spogit/cache/cache_types.dart'; 7 | import 'package:Spogit/cache/cover_resource.dart'; 8 | import 'package:Spogit/cache/id/id_resource.dart'; 9 | import 'package:Spogit/cache/id/id_resource_manager.dart'; 10 | import 'package:Spogit/change_watcher.dart'; 11 | import 'package:Spogit/driver/driver_api.dart'; 12 | import 'package:Spogit/driver/playlist_manager.dart'; 13 | import 'package:Spogit/fs/playlist.dart'; 14 | import 'package:Spogit/git_hook.dart'; 15 | import 'package:Spogit/input_controller.dart'; 16 | import 'package:Spogit/local_manager.dart'; 17 | import 'package:Spogit/setup.dart'; 18 | import 'package:Spogit/utility.dart'; 19 | import 'package:logging/logging.dart'; 20 | 21 | class Spogit { 22 | final log = Logger('Spogit'); 23 | 24 | final GitHook gitHook; 25 | final ChangeWatcher changeWatcher; 26 | final DriverAPI driverAPI; 27 | final CacheManager cacheManager; 28 | final IdResourceManager idResourceManager; 29 | final AlbumResourceManager albumResourceManager; 30 | final Directory spogitPath; 31 | 32 | PlaylistManager get playlistManager => driverAPI?.playlistManager; 33 | 34 | Spogit._(this.spogitPath, this.gitHook, this.changeWatcher, this.driverAPI, this.cacheManager, 35 | this.idResourceManager, this.albumResourceManager); 36 | 37 | static Future createSpogit(Directory spogitPath, File cookiesFile, File chromedriverFile, File cacheFile, {int treeDuration = 2, int playlistDuration = 2}) async { 38 | await [spogitPath, '.spogit'].fileRaw.create(recursive: true); 39 | 40 | final cacheManager = CacheManager(cacheFile) 41 | ..registerType(CacheType.PLAYLIST_COVER, 42 | (id, map) => PlaylistCoverResource.fromPacked(id, map)) 43 | ..registerType(CacheType.ID, (id, map) => IdResource.fromPacked(id, map)); 44 | await cacheManager.readCache(); 45 | cacheManager.scheduleWrites(); 46 | 47 | final driverAPI = DriverAPI(cookiesFile, chromedriverFile); 48 | await driverAPI.startDriver(); 49 | 50 | final changeWatcher = ChangeWatcher(driverAPI, treeDuration: treeDuration, playlistDuration: playlistDuration); 51 | final gitHook = GitHook(); 52 | 53 | final idResourceManager = 54 | IdResourceManager(driverAPI.playlistManager, cacheManager); 55 | final albumResourceManager = 56 | AlbumResourceManager(driverAPI.playlistManager, cacheManager); 57 | 58 | return Spogit._(spogitPath, gitHook, changeWatcher, driverAPI, cacheManager, 59 | idResourceManager, albumResourceManager); 60 | } 61 | 62 | Future start() async { 63 | final manager = LocalManager(this, driverAPI, cacheManager, spogitPath); 64 | final inputController = InputController(this, manager); 65 | 66 | await gitHook.listen(); 67 | gitHook.postCheckout.stream.listen((data) async { 68 | var wd = data.workingDirectory; 69 | 70 | if (directoryEquals(wd.parent, spogitPath)) { 71 | var foundLocal = manager.getPlaylist(wd); 72 | if (foundLocal == null) { 73 | log.info('Creating playlist at ${wd.path}'); 74 | 75 | changeWatcher.lock(); 76 | 77 | var linked = 78 | LinkedPlaylist.fromLocal(this, manager, normalizeDir(wd).directory); 79 | manager.addPlaylist(linked); 80 | await linked.initLocal(); 81 | await linked.root.save(); 82 | 83 | Timer(Duration(seconds: 2), () => changeWatcher.unlock()); 84 | } else { 85 | log.info( 86 | 'Playlist already exists locally! No need to create it, updating from local...'); 87 | await foundLocal.initLocal(); 88 | } 89 | } else { 90 | log.warning('Not a direct child in ${spogitPath.path}'); 91 | } 92 | }); 93 | 94 | var currRevision = await playlistManager.analyzeBaseRevision(); 95 | 96 | final existing = await manager.getExistingRoots(currRevision); 97 | 98 | log.info('Got ${existing.length} existing'); 99 | 100 | changeWatcher.watchChanges(currRevision, existing, 101 | (baseRevision, linkedPlaylist, ids) => linkedPlaylist.pullRemote(baseRevision, ids)); 102 | 103 | log.info('Watching for playlist changes...'); 104 | changeWatcher.watchPlaylistChanges(manager, (changed) async { 105 | var res = >{}; 106 | for (var id in changed.keys) { 107 | bruh: 108 | for (var exist in existing) { 109 | var searched = exist.root.searchForId(id) as SpotifyPlaylist; 110 | if (searched != null) { 111 | var playlistDetails = await playlistManager.getPlaylistInfo(id); 112 | 113 | res.putIfAbsent(exist.root, () => {})[id] = changed[id]; 114 | 115 | searched 116 | ..description = playlistDetails.description 117 | ..imageUrl = manager.getCoverUrl( 118 | id, playlistDetails.images?.safeFirst?.url) 119 | ..songs = List.from(playlistDetails?.tracks?.items 120 | ?.map((track) => SpotifySong.fromJson(this, track)) ?? const []); 121 | await searched.save(); 122 | 123 | break bruh; 124 | } 125 | } 126 | } 127 | 128 | log.info('Updated change stuff: ${changed}'); 129 | return res; 130 | }); 131 | 132 | inputController.start(spogitPath); 133 | } 134 | 135 | bool directoryEquals(Directory one, Directory two) => 136 | listEquals(normalizeDir(one), normalizeDir(two)); 137 | 138 | List normalizeDir(Directory directory) { 139 | var segments = directory.uri.pathSegments; 140 | return [ 141 | ...segments 142 | ]..replaceRange(0, 1, ['${segments.first.substring(0, 1).toLowerCase()}:']); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /lib/cache/album/album_resource.dart: -------------------------------------------------------------------------------- 1 | import 'package:Spogit/cache/cache_types.dart'; 2 | import 'package:Spogit/cache/cached_resource.dart'; 3 | import 'package:Spogit/json/album_full.dart'; 4 | import 'package:Spogit/utility.dart'; 5 | 6 | /// Stores the associated name with an ID, weather it be a track, playlist, or 7 | /// folder. 8 | class AlbumResource extends CachedResource { 9 | AlbumResource(String id, AlbumFull album) 10 | : super(id.customHash, CacheType.ID, now, album); 11 | 12 | AlbumResource.fromPacked(int id, Map map) 13 | : super(id, CacheType.ID, now, map['data']); 14 | 15 | @override 16 | Map pack() => data.toJson(); 17 | 18 | @override 19 | String toString() => 'AlbumResource{id = $id, data = $data}'; 20 | } 21 | -------------------------------------------------------------------------------- /lib/cache/album/album_resource_manager.dart: -------------------------------------------------------------------------------- 1 | import 'package:Spogit/cache/album/album_resource.dart'; 2 | import 'package:Spogit/cache/cache_manager.dart'; 3 | import 'package:Spogit/cache/cache_types.dart'; 4 | import 'package:Spogit/driver/playlist_manager.dart'; 5 | import 'package:Spogit/json/album_full.dart'; 6 | import 'package:logging/logging.dart'; 7 | 8 | class AlbumResourceManager { 9 | 10 | final log = Logger('AlbumResourceManager'); 11 | 12 | final PlaylistManager playlistManager; 13 | final CacheManager cacheManager; 14 | 15 | AlbumResourceManager(this.playlistManager, this.cacheManager); 16 | 17 | /// Gets of retrieves the album JSON with the given ID. 18 | Future getAlbumFromId(String albumId) async { 19 | albumId = albumId.parseId; 20 | 21 | var albums = cacheManager.getAllOfType(CacheType.ALBUM); 22 | var foundAlbum = albums.firstWhere((album) => album.data.id == albumId, orElse: () => null); 23 | 24 | if (foundAlbum != null) { 25 | return foundAlbum.data; 26 | } 27 | 28 | // The full album must be gotten for access for the tracks 29 | var fullAlbum = await playlistManager.getAlbum(albumId); 30 | 31 | cacheManager[albumId] = AlbumResource(albumId, fullAlbum); 32 | 33 | return fullAlbum; 34 | } 35 | 36 | /// Gets or retrieves the album JSON for the associated track ID. 37 | Future getAlbumFromTrack(String track) async { 38 | track = track.parseId; 39 | 40 | var albums = cacheManager.getAllOfType(CacheType.ALBUM); 41 | var foundAlbum = albums.firstWhere((album) => album.data.tracks.items.any((trackObj) => trackObj.id == track), orElse: () => null); 42 | 43 | if (foundAlbum != null) { 44 | return foundAlbum.data; 45 | } 46 | 47 | var gottenTrack = await playlistManager.getTrack(track); 48 | if (gottenTrack == null) { 49 | log.severe('Gotten track for ID "$track" is null!'); 50 | return null; 51 | } 52 | 53 | var album = gottenTrack.album; 54 | 55 | // The full album must be gotten for access for the tracks 56 | var fullAlbum = await playlistManager.getAlbum(album.id); 57 | 58 | cacheManager[album.id] = AlbumResource(album.id, fullAlbum); 59 | 60 | return fullAlbum; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lib/cache/cache_manager.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:Spogit/cache/cache_types.dart'; 6 | import 'package:Spogit/cache/cached_resource.dart'; 7 | import 'package:Spogit/utility.dart'; 8 | import 'package:logging/logging.dart'; 9 | import 'package:msgpack2/msgpack2.dart' as msgpack; 10 | 11 | /// Manages caches for arbitrary resources such as playlist images. 12 | class CacheManager { 13 | final log = Logger('CacheManager'); 14 | 15 | final File cacheFile; 16 | 17 | /// The resource type as the key, and the generator from unpacked data. 18 | final Map functionGenerator = {}; 19 | 20 | /// The resource ID as the key, and the [CachedResource] as the value. 21 | final Map cache = {}; 22 | 23 | /// The time in seconds cache may live by default. This may be overridden by 24 | /// a [CachedResource]. 25 | final int cacheLife; 26 | 27 | bool modified = false; 28 | 29 | CacheManager(this.cacheFile, {this.cacheLife = 3600}); 30 | 31 | void registerType(CacheType resourceType, 32 | CachedResource Function(int, Map) cacheGenerator) => 33 | functionGenerator[resourceType.id] = cacheGenerator; 34 | 35 | /// Reads caches from the file into memory. 36 | Future readCache() async { 37 | if (!(await cacheFile.exists())) { 38 | return; 39 | } 40 | 41 | var unpacked = jsonDecode(await cacheFile.readAsString()) as Map; 42 | for (var id in unpacked.keys) { 43 | /* 44 | 12345678: { // The ID 45 | 'type': 1, 46 | 'data': { /* specific to type */ } 47 | } 48 | */ 49 | 50 | var data = unpacked[id]; 51 | var type = data['type']; 52 | var generator = functionGenerator[type]; 53 | id = (id as String).parseInt(); 54 | 55 | if (generator == null) { 56 | log.warning('No generator found for resource type $type'); 57 | continue; 58 | } 59 | 60 | var resource = generator.call(id, data); 61 | 62 | if (resource == null) { 63 | log.warning('Null resource created'); 64 | continue; 65 | } 66 | 67 | cache[id] = resource; 68 | } 69 | } 70 | 71 | /// Writes all caches to the cache file if they have been modified. 72 | Future writeCache() async { 73 | if (!modified) { 74 | return; 75 | } 76 | 77 | log.fine('Writing caches...'); 78 | modified = false; 79 | 80 | var file = await cacheFile.writeAsString( 81 | jsonEncode(cache.map((id, cache) => 82 | MapEntry('$id', { 83 | 'type': cache.type.id, 84 | 'createdAt': cache.createdAt, 85 | 'data': cache.pack() 86 | })))); 87 | 88 | log.fine('Cache file is ${file.lengthSync()} bytes'); 89 | } 90 | 91 | /// Removes all cache elements with an ID in the given [keys] list. 92 | void clearCacheFor(List keys) { 93 | for (var key in keys) { 94 | cache.remove(customHash(key)); 95 | } 96 | } 97 | 98 | /// Schedules writes for the given [Duration], or by default every 10 seconds 99 | /// only if the cache has been updated. 100 | void scheduleWrites([Duration duration = const Duration(seconds: 10)]) => 101 | Timer.periodic(duration, (_) async => await writeCache()); 102 | 103 | /// Gets if the cache contains the key. 104 | bool containsKey(dynamic id) => cache.containsKey(id.customHash); 105 | 106 | /// Gets all cache values of the given [type]. 107 | List getAllOfType(CacheType type) => 108 | cache.values.whereType().toList(); 109 | 110 | /// Identical to [getOr] but for always-synchronous [resourceGenerator]s. 111 | GetOrResult getOrSync(dynamic id, 112 | CachedResource Function() resourceGenerator, 113 | {bool Function(CachedResource) forceUpdate}) { 114 | var handled = _handleGetOr(id, forceUpdate); 115 | return GetOrResult((handled[0] ?? (cache[handled[1]] = resourceGenerator())) as T, handled[2]); 116 | } 117 | 118 | /// Gets a resource from an [id] which may be anything, as it is transformed 119 | /// via [customHash]. If it is not found or expired, [resourceGenerator] is 120 | /// invoked and set to the [id]. 121 | /// 122 | /// If [forceUpdate] is set, it should return a boolean for if the 123 | /// [resourceGenerator] should be invoked regardless of expiration level. 124 | /// This is used for things like comparing internal data values of the 125 | /// resource. 126 | Future> getOr(dynamic id, 127 | FutureOr Function() resourceGenerator, 128 | {bool Function(CachedResource) forceUpdate}) async { 129 | var handled = _handleGetOr(id, forceUpdate); 130 | return GetOrResult(handled[0] ?? (cache[handled[1]] = await resourceGenerator()), handled[2]); 131 | } 132 | 133 | /// Handles `getOr` methods. Returns the cached variable or null. In the case 134 | /// of returning null 135 | List _handleGetOr(dynamic id, 136 | bool Function(CachedResource) forceUpdate) { 137 | id = id is int ? id : CustomHash(id).customHash; 138 | var cached = cache[id]; 139 | if ((cached?.isExpired() ?? true) || (forceUpdate?.call(cached) ?? false)) { 140 | modified = true; 141 | return [null, id, true]; 142 | } 143 | return [cached, null, false]; 144 | } 145 | 146 | /// Gets a resource from an [id] which may be anything, as it is transformed 147 | /// via [customHash]. Returns an instance of [CachedResource]. 148 | CachedResource operator [](dynamic id) { 149 | id = id is int ? id : CustomHash(id).customHash; 150 | return cache[id]; 151 | } 152 | 153 | /// Sets a resource to a given [id] which may be anything, as it is 154 | /// transformed via [customHash]. 155 | operator []=(dynamic id, CachedResource resource) { 156 | id = id is int ? id : CustomHash(id).customHash; 157 | cache[id] = resource; 158 | modified = true; 159 | } 160 | } 161 | 162 | class GetOrResult { 163 | final T resource; 164 | final bool generated; 165 | 166 | GetOrResult(this.resource, this.generated); 167 | } 168 | -------------------------------------------------------------------------------- /lib/cache/cache_types.dart: -------------------------------------------------------------------------------- 1 | import 'package:Spogit/cache/album/album_resource.dart'; 2 | import 'package:Spogit/cache/cached_resource.dart'; 3 | import 'package:Spogit/cache/cover_resource.dart'; 4 | import 'package:Spogit/cache/id/id_resource.dart'; 5 | 6 | class CacheType { 7 | static const PLAYLIST_COVER = CacheType._('PlaylistCover', 1, 315360000 /* 10 years, the max-age of the image */); 8 | static const ID = CacheType._('ID', 2, 315360000 /* Shouldn't ever expire */); 9 | static const ALBUM = CacheType._('Album', 3, 86400 /* 1 Day */); 10 | 11 | final String name; 12 | final int id; 13 | final int ttl; 14 | 15 | const CacheType._(this.name, this.id, this.ttl); 16 | 17 | @override 18 | String toString() { 19 | return 'CacheType{name: $name, id: $id, ttl: $ttl}'; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/cache/cached_resource.dart: -------------------------------------------------------------------------------- 1 | 2 | import 'package:Spogit/cache/cache_types.dart'; 3 | import 'package:Spogit/utility.dart'; 4 | 5 | abstract class CachedResource { 6 | final int id; 7 | final CacheType type; 8 | final int createdAt; 9 | final T data; 10 | 11 | CachedResource(this.id, this.type, this.createdAt, this.data); 12 | 13 | /// Packs the [data] of the resource. The [id] and [type] are already handled 14 | /// and should not be a factor in this packing. 15 | Map pack(); 16 | 17 | /// If more seconds than the [type]'s ttl has passed. 18 | bool isExpired() => now - createdAt > type.ttl; 19 | } 20 | -------------------------------------------------------------------------------- /lib/cache/cover_resource.dart: -------------------------------------------------------------------------------- 1 | import 'package:Spogit/cache/cache_types.dart'; 2 | import 'package:Spogit/cache/cached_resource.dart'; 3 | import 'package:Spogit/utility.dart'; 4 | 5 | class PlaylistCoverResource extends CachedResource { 6 | PlaylistCoverResource(String id, String url) 7 | : super(id.customHash, CacheType.PLAYLIST_COVER, now, url); 8 | 9 | PlaylistCoverResource.fromPacked(int id, Map map) 10 | : super(id, CacheType.PLAYLIST_COVER, now, map['data']['url']); 11 | 12 | @override 13 | Map pack() => {'url': data}; 14 | 15 | @override 16 | String toString() => 'PlaylistCoverResource{id = $id, url = $data}'; 17 | } 18 | -------------------------------------------------------------------------------- /lib/cache/id/id_resource.dart: -------------------------------------------------------------------------------- 1 | import 'package:Spogit/cache/cache_types.dart'; 2 | import 'package:Spogit/cache/cached_resource.dart'; 3 | import 'package:Spogit/utility.dart'; 4 | 5 | /// Stores the associated name with an ID, weather it be a track, playlist, or 6 | /// folder. 7 | class IdResource extends CachedResource { 8 | IdResource(String id, String name) 9 | : super(id.customHash, CacheType.ID, now, name); 10 | 11 | IdResource.fromPacked(int id, Map map) 12 | : super(id, CacheType.ID, now, map['data']['name']); 13 | 14 | @override 15 | Map pack() => {'name': data}; 16 | 17 | @override 18 | String toString() => 'IdResource{id = $id, name = $data}'; 19 | } 20 | -------------------------------------------------------------------------------- /lib/cache/id/id_resource_manager.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:Spogit/cache/cache_manager.dart'; 4 | import 'package:Spogit/cache/id/id_resource.dart'; 5 | import 'package:Spogit/driver/driver_api.dart'; 6 | import 'package:Spogit/driver/playlist_manager.dart'; 7 | import 'package:Spogit/utility.dart'; 8 | 9 | class IdResourceManager { 10 | final REQUEST_ORDER = const [ 11 | ResourceType.Track, 12 | ResourceType.Playlist, 13 | ResourceType.Folder 14 | ]; 15 | 16 | final PlaylistManager playlistManager; 17 | final CacheManager cacheManager; 18 | 19 | IdResourceManager(this.playlistManager, this.cacheManager); 20 | 21 | /// Gets or retrieves the name of a 22-character Spotify resource ID. If the 22 | /// ID is already is already in the cache the prefixing `spotify:whatever` is 23 | /// not relevant, however when fetching it is more efficient to have it given 24 | /// as it will not have to potentially make multiple requests per ID. 25 | /// The order of requests is defined by the constant [REQUEST_ORDER] but by 26 | /// default is: 27 | /// - Track 28 | /// - Playlist 29 | /// - Folder 30 | Future getName(String id, [ResourceType type]) => 31 | id == null ? 'null' : cacheManager.getOr(id, () async { 32 | type ??= getResourceType(id); 33 | var parsed = id.parseId; 34 | String name; 35 | if (type != null) { 36 | name = await tryRequest(parsed, type); 37 | } else { 38 | for (var orderType in REQUEST_ORDER) { 39 | var requested = await tryRequest(parsed, orderType); 40 | if (requested != null) { 41 | name = requested; 42 | break; 43 | } 44 | } 45 | } 46 | return IdResource(parsed, name); 47 | }, forceUpdate: (prev) => prev.data == null).then( 48 | (res) => res.resource.data); 49 | 50 | FutureOr tryRequest(String id, ResourceType resource) { 51 | switch (resource) { 52 | case ResourceType.Track: 53 | return playlistManager.getTrack(id).then((json) => json?.name); 54 | break; 55 | case ResourceType.Playlist: 56 | return playlistManager.getPlaylistInfo(id).then((json) => json?.name); 57 | case ResourceType.Folder: 58 | return id.startsWith('spotify:start-group') ? id.replaceFirst('spotify:start-group:', '').safeSubstring(22)?.replaceAll('+', '') : null; 59 | } 60 | 61 | return null; 62 | } 63 | 64 | ResourceType getResourceType(String id) { 65 | var start = id.replaceFirst('spotify:', ''); 66 | if (start.startsWith('track')) { 67 | return ResourceType.Track; 68 | } else if (start.startsWith('playlist')) { 69 | return ResourceType.Playlist; 70 | } else if (start.startsWith('start-group') || 71 | start.startsWith('end-group')) { 72 | return ResourceType.Folder; 73 | } 74 | 75 | return null; 76 | } 77 | } 78 | 79 | enum ResourceType { Track, Playlist, Folder } 80 | -------------------------------------------------------------------------------- /lib/change_watcher.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:Spogit/cache/cache_manager.dart'; 4 | import 'package:Spogit/driver/driver_api.dart'; 5 | import 'package:Spogit/driver/playlist_manager.dart'; 6 | import 'package:Spogit/fs/playlist.dart'; 7 | import 'package:Spogit/local_manager.dart'; 8 | import 'package:Spogit/utility.dart'; 9 | import 'package:logging/logging.dart'; 10 | 11 | class ChangeWatcher { 12 | final log = Logger('ChangeWatcher'); 13 | 14 | final DriverAPI driverAPI; 15 | final int treeDuration; 16 | final int playlistDuration; 17 | bool _lock = false; 18 | bool _nextUnlock = false; 19 | 20 | // The last etag for the playlist tree request 21 | String previousETag; 22 | 23 | ChangeWatcher(this.driverAPI, {this.treeDuration = 2, this.playlistDuration = 2}); 24 | 25 | void lock() { 26 | _lock = true; 27 | previousETag = ''; 28 | } 29 | 30 | void unlock() { 31 | _nextUnlock = true; 32 | } 33 | 34 | /// Watches for changes in the playlist tree 35 | void watchAllChanges(Function(BaseRevision) callback) { 36 | 37 | Timer.periodic(Duration(seconds: treeDuration), (timer) async { 38 | var etag = await driverAPI.playlistManager.baseRevisionETag(); 39 | 40 | if (_lock) { 41 | previousETag = etag; 42 | _lock = !_nextUnlock; 43 | return; 44 | } 45 | 46 | if (etag == previousETag) { 47 | return; 48 | } 49 | 50 | var sendCallback = previousETag != null; 51 | 52 | previousETag = etag; 53 | 54 | if (sendCallback) { 55 | log.fine('Playlist tree has changed!'); 56 | callback(await driverAPI.playlistManager.analyzeBaseRevision()); 57 | } 58 | }); 59 | } 60 | 61 | /// Watches changes to the base [tracking] elements. This only watches for 62 | /// tree changes, and not playlist changes. 63 | void watchChanges(BaseRevision baseRevision, List tracking, 64 | Function(BaseRevision, LinkedPlaylist, List) callback) { 65 | var previousHashes = >{}; 66 | 67 | for (var exist in tracking) { 68 | var theseHashes = {}; 69 | 70 | var trackingIds = exist.root.rootLocal?.tracking; 71 | for (var track in trackingIds) { 72 | var hash = baseRevision.getHash(id: track); 73 | theseHashes[track] = hash; 74 | } 75 | 76 | previousHashes[exist] = theseHashes; 77 | } 78 | 79 | watchAllChanges((revision) async { 80 | log.fine('It has changed on the Spotify side!'); 81 | 82 | for (var exist in tracking) { 83 | // The fully qualified track/playlist ID and its hash 84 | var theseHashes = {}; 85 | var trackingIds = exist.root.rootLocal.tracking; 86 | 87 | for (var track in trackingIds) { 88 | var hash = revision.getHash(id: track); 89 | theseHashes[track] = hash; 90 | } 91 | 92 | var prevHash = {...previousHashes.putIfAbsent(exist, () => {})}; 93 | 94 | var difference = _getDifference(prevHash, theseHashes).keys; 95 | log.fine( 96 | 'previous = ${prevHash.keys.toList().map((k) => '$k: ${prevHash[k].toRadixString(16)}').join(', ')}'); 97 | log.fine( 98 | 'theseHashes = ${theseHashes.keys.toList().map((k) => '$k: ${theseHashes[k].toRadixString(16)}').join(', ')}'); 99 | 100 | if (difference.isNotEmpty) { 101 | log.fine( 102 | 'Difference between hashes! Reloading ${exist.root.root.uri.realName} trackingIds: $difference'); 103 | callback(revision, exist, difference.toList()); 104 | } else { 105 | log.fine('No hash difference for ${exist.root.root.uri.realName}'); 106 | } 107 | 108 | previousHashes[exist] = theseHashes; 109 | exist.root.rootLocal.revision = revision.revision; 110 | } 111 | }); 112 | } 113 | 114 | /// Watches for changes in any playlist tracks or meta. [callback] is invoked 115 | /// with a parsed playlist ID every time it is updated. 116 | void watchPlaylistChanges(LocalManager localManager, Future>> Function(Map) callback) { 117 | // parsed playlist ID, snapshot 118 | final allSnapshots = {}; 119 | 120 | for (var linked in localManager.linkedPlaylists) { 121 | allSnapshots.addAll(trimStuff(linked.root.rootLocal.snapshotIds)); 122 | } 123 | 124 | Timer.periodic(Duration(seconds: playlistDuration), (timer) async { 125 | if (_lock) { 126 | return; 127 | } 128 | 129 | var snapshots = trimStuff(await driverAPI.playlistManager.getPlaylistSnapshots()); 130 | if (allSnapshots.isEmpty) { 131 | allSnapshots.addAll(snapshots); 132 | return; 133 | } 134 | 135 | var diff = _getDifference(allSnapshots, snapshots); 136 | if (diff.isNotEmpty) { 137 | await callback(diff).then((map) { 138 | for (var root in map.keys) { 139 | var local = root.rootLocal; 140 | local.snapshotIds = {...local.snapshotIds, ...map[root]}; 141 | local.saveFile(); 142 | } 143 | }); 144 | 145 | // allSnapshots.clear(); 146 | allSnapshots.addAll(snapshots); 147 | } 148 | }); 149 | } 150 | 151 | Map trimStuff(Map map) => map.map((k, v) => MapEntry(k, v.substring(31))); 152 | 153 | Map _getDifference(Map first, Map second) { 154 | var res = {}; 155 | void checkMaps(Map one, Map two) { 156 | for (var id in one.keys) { 157 | if (!two.containsKey(id) || two[id] != one[id]) { 158 | res[id] = one[id]; 159 | } 160 | } 161 | } 162 | 163 | checkMaps(first, second); 164 | checkMaps(second, first); 165 | 166 | return res; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /lib/driver/driver_api.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:Spogit/driver/driver_request.dart'; 6 | import 'package:Spogit/driver/js_communication.dart'; 7 | import 'package:Spogit/driver/playlist_manager.dart'; 8 | import 'package:Spogit/driver_utility.dart'; 9 | import 'package:Spogit/setup.dart'; 10 | import 'package:Spogit/utility.dart'; 11 | import 'package:logging/logging.dart'; 12 | import 'package:webdriver/sync_io.dart'; 13 | 14 | class DriverAPI { 15 | final log = Logger('DriverAPI'); 16 | 17 | final File cookiesFile; 18 | final File chromeDriverFile; 19 | WebDriver driver; 20 | 21 | JSCommunication communication; 22 | RequestManager requestManager; 23 | PlaylistManager playlistManager; 24 | 25 | DriverAPI(this.cookiesFile, this.chromeDriverFile); 26 | 27 | Future startDriver() async { 28 | final runner = WebDriverRunner(); 29 | await runner.start(chromeDriverFile); 30 | 31 | driver = runner.driver; 32 | 33 | communication = await JSCommunication.startCommunication(); 34 | 35 | requestManager = RequestManager(driver, communication); 36 | 37 | await getCredentials(); 38 | 39 | await requestManager.initAuth(); 40 | 41 | playlistManager = await PlaylistManager.createPlaylistManager( 42 | driver, requestManager, communication); 43 | } 44 | 45 | Future getCredentials() async { 46 | if (await cookiesFile.exists()) { 47 | driver.get('https://open.spotify.com/'); 48 | var json = jsonDecode(cookiesFile.readAsStringSync()); 49 | json.forEach((cookie) => driver.cookies.add(Cookie.fromJson(cookie))); 50 | 51 | driver.get('https://open.spotify.com/'); 52 | return; 53 | } else { 54 | await Setup().setup(); 55 | } 56 | 57 | driver.get( 58 | 'https://accounts.spotify.com/en/login?continue=https:%2F%2Fopen.spotify.com%2F'); 59 | 60 | await getElement(driver, By.cssSelector('.Root__main-view'), 61 | duration: 60000, checkInterval: 1000); 62 | 63 | log.info('Logged in'); 64 | 65 | jsonEncode(driver.cookies.all.map((cookie) => cookie.toJson()).toList()) >> 66 | cookiesFile; 67 | } 68 | 69 | Map getLocalStorage() => 70 | driver.execute('return window.localStorage;', []); 71 | 72 | void setLocalStorage(String key, String value) => driver.execute( 73 | 'window.localStorage.setItem(arguments[0], arguments[1])', [key, value]); 74 | } 75 | 76 | class WebDriverRunner { 77 | final log = Logger('WebDriverRunner'); 78 | 79 | Process _process; 80 | 81 | WebDriver _driver; 82 | 83 | WebDriver get driver => _driver; 84 | 85 | Future start(File chromedriver, 86 | [int chromeDriverPort = 4569]) async { 87 | if (await isOpen(chromeDriverPort)) { 88 | log.info('Starting chromedriver...'); 89 | 90 | _process = await Process.start(chromedriver.path, [ 91 | '--port=$chromeDriverPort', 92 | '--url-base=wd/hub', 93 | ]); 94 | 95 | await for (var out 96 | in const LineSplitter().bind(utf8.decoder.bind(_process.stdout))) { 97 | if (out.contains('Starting ChromeDriver')) { 98 | break; 99 | } 100 | } 101 | 102 | log.info('Started chromedriver with PID ${_process.pid}'); 103 | } else { 104 | log.info('Looks like chromedriver is already running'); 105 | } 106 | 107 | _driver = await createDriver( 108 | uri: Uri.parse('http://localhost:${chromeDriverPort}/wd/hub/'), 109 | desired: { 110 | 'browserName': 'chrome', 111 | 'goog:loggingPrefs': {'browser': 'ALL'}, 112 | 'goog:chromeOptions': { 113 | 'args': [ 114 | '--disable-web-security', 115 | '--allow-running-insecure-content', 116 | '--enable-automation', 117 | '--enable-logging', 118 | '--log-level=0', 119 | '--test-type=webdriver', 120 | '--net-log-level=0', 121 | '--log-severity=all', 122 | '--auto-open-devtools-for-tabs', 123 | ], 124 | }, 125 | }); 126 | 127 | log.info('Started driver'); 128 | } 129 | 130 | void stop() { 131 | _driver.quit(closeSession: true); 132 | _process?.kill(); 133 | } 134 | 135 | Future isOpen(int port) => 136 | ServerSocket.bind('127.0.0.1', port).then((socket) { 137 | socket.close(); 138 | return true; 139 | }).catchError((_) => false); 140 | } 141 | -------------------------------------------------------------------------------- /lib/driver/driver_request.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | 4 | import 'package:Spogit/driver/js_communication.dart'; 5 | import 'package:Spogit/driver_utility.dart'; 6 | import 'package:Spogit/json/json.dart'; 7 | import 'package:Spogit/json/paging.dart'; 8 | import 'package:Spogit/utility.dart'; 9 | import 'package:http/http.dart' as http; 10 | import 'package:webdriver/sync_io.dart'; 11 | 12 | class RequestManager { 13 | final WebDriver _driver; 14 | final JSCommunication _communication; 15 | 16 | PersonalData personalData; 17 | String authToken; 18 | 19 | RequestManager(this._driver, this._communication); 20 | 21 | Future initAuth() async { 22 | var authCompleter = Completer(); 23 | 24 | StreamSubscription sub; 25 | sub = _communication.stream.listen((message) async { 26 | var headers = access(message.value['1'], 'headers'); 27 | 28 | var authorization = access(headers, 'authorization'); 29 | if (authorization == null) { 30 | return; 31 | } 32 | 33 | await sub?.cancel(); 34 | 35 | authToken = authorization.substring(7); 36 | 37 | var meResponse = await DriverRequest( 38 | method: RequestMethod.Get, 39 | uri: Uri.parse('https://api.spotify.com/v1/me'), 40 | token: authToken) 41 | .send(); 42 | 43 | personalData = PersonalData.fromJson(meResponse.json); 44 | 45 | authCompleter.complete(); 46 | }); 47 | 48 | Future tryShit() async { 49 | _driver.execute(''' 50 | const authSocket = new WebSocket(`ws://localhost:6979`); 51 | window.aa = authSocket; 52 | constantMock = window.fetch; 53 | authSocket.onopen = () => { 54 | window.fetch = function () { 55 | if (arguments[0].includes('spotify.com')) { 56 | console.log(arguments); 57 | authSocket.send(JSON.stringify({'type': 'http', 'value': arguments})); 58 | window.fetch = constantMock; 59 | } 60 | return constantMock.apply(this, arguments) 61 | }; 62 | }; 63 | ''', []); 64 | 65 | await awaitSleep(Duration(milliseconds: 500)); 66 | 67 | (await getElement(_driver, By.cssSelector('a[href="/collection"]'), 68 | duration: 500)) 69 | ?.click(); 70 | 71 | if (_driver 72 | .findElements(By.cssSelector( 73 | 'div[aria-label="Something went wrong"] button')) 74 | .isNotEmpty && 75 | authToken == null) { 76 | _driver.get('https://open.spotify.com/'); 77 | return tryShit(); 78 | } 79 | 80 | await awaitSleep(Duration(milliseconds: 3000)); 81 | 82 | if (personalData == null) { 83 | _driver.get('https://open.spotify.com/'); 84 | return tryShit(); 85 | } 86 | } 87 | 88 | await tryShit(); 89 | 90 | return authCompleter.future; 91 | } 92 | } 93 | 94 | class DriverRequest { 95 | final RequestMethod method; 96 | final Uri uri; 97 | final Map headers; 98 | final dynamic body; 99 | 100 | DriverRequest({ 101 | String token, 102 | RequestMethod method, 103 | this.uri, 104 | this.body, 105 | Map headers = const {}, 106 | }) : method = method ?? RequestMethod.Post, 107 | headers = { 108 | ...headers, 109 | ...{ 110 | if (token != null) ...{'authorization': 'Bearer $token'}, 111 | 'accept': 'application/json', 112 | 'content-type': 'application/json;charset=UTF-8', 113 | 'accept-language': 'en', 114 | 'app-platform': 'WebPlayer', 115 | 'spotify-app-version': '1587143698', 116 | } 117 | }; 118 | 119 | /// Sends the current request. 120 | Future send() => 121 | method.send(uri.toString(), body: body, headers: headers); 122 | 123 | /// Sends the current paging request. The [pageLimit] is the amount of items 124 | /// requested per request. If [all] is true, it will keep requesting until all 125 | /// items have been retrieved, which may take a while. If it is false, it will 126 | /// request until [maxRequests] has been hit, or until all items have been 127 | /// requested, whichever comes first. 128 | Future> sendPaging( 129 | T Function(Map) pagingConvert, 130 | {int pageLimit = 50, 131 | int maxRequests = 1, 132 | bool all = false}) async { 133 | var result = []; 134 | 135 | Paging paging; 136 | do { 137 | var response = await _send(paging?.next ?? 138 | uri.replace(queryParameters: { 139 | ...uri.queryParameters, 140 | if (pageLimit != null) 'limit': '$pageLimit', 141 | 'offset': '0', 142 | }).toString()); 143 | 144 | if (response.statusCode >= 300) { 145 | break; 146 | } 147 | 148 | paging = Paging.fromJson(response.json, pagingConvert); 149 | result.addAll(paging.items); 150 | } while (paging.next != null && (--maxRequests > 0 || all)); 151 | 152 | return result; 153 | } 154 | 155 | Future _send(String uriString) => 156 | method.send(uriString, body: body, headers: headers); 157 | } 158 | 159 | class RequestMethod { 160 | static final RequestMethod Get = 161 | RequestMethod._((url, headers, body) => http.get(url, headers: headers)); 162 | 163 | static final RequestMethod Post = RequestMethod._((url, headers, body) => 164 | http.post(url, headers: headers, body: jsonEncode(body))); 165 | 166 | static final RequestMethod Head = 167 | RequestMethod._((url, headers, body) => http.head(url, headers: headers)); 168 | 169 | static final RequestMethod Delete = RequestMethod._( 170 | (url, headers, body) => http.delete(url, headers: headers)); 171 | 172 | static final RequestMethod Put = RequestMethod._( 173 | (url, headers, body) => http.put(url, headers: headers, body: body)); 174 | 175 | final Future Function( 176 | String url, Map headers, dynamic body) request; 177 | 178 | const RequestMethod._(this.request); 179 | 180 | Future send(String url, 181 | {Map headers, dynamic body}) async => 182 | await request(url, headers, body); 183 | } 184 | 185 | class PersonalData { 186 | final String birthdate; 187 | final String country; 188 | final String displayName; 189 | final String email; 190 | final Uri spotifyUrl; 191 | final int followers; 192 | final String id; 193 | final String uri; 194 | 195 | PersonalData.fromJson(Map json) 196 | : birthdate = json['birthdate'], 197 | country = json['country'], 198 | displayName = json['display_name'], 199 | email = json['email'], 200 | spotifyUrl = (access(json['external_urls'], 'spotify') as String)?.uri, 201 | followers = access(json['followers'], 'total') as int, 202 | id = json['id'], 203 | uri = json['uri']; 204 | } 205 | -------------------------------------------------------------------------------- /lib/driver/js_communication.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | 6 | class JSCommunication { 7 | final int port; 8 | final _broadcast = StreamController.broadcast(); 9 | HttpServer _httpServer; 10 | 11 | Stream get stream => _broadcast.stream; 12 | 13 | JSCommunication._(this.port); 14 | 15 | static Future startCommunication([int port = 6979]) async { 16 | final communication = JSCommunication._(port); 17 | await communication.startListener(); 18 | return communication; 19 | } 20 | 21 | Future startListener() async { 22 | if (_httpServer != null) { 23 | throw 'HttpServer already initialized'; 24 | } 25 | 26 | _httpServer = await HttpServer.bind('localhost', port); 27 | _httpServer.listen((req) async { 28 | if (req.uri.path == '/') { 29 | var socket = await WebSocketTransformer.upgrade(req); 30 | socket.listen((data) => _broadcast.add(JsonMessage.fromJSON(jsonDecode(data)))); 31 | } 32 | }); 33 | } 34 | } 35 | 36 | class JsonMessage { 37 | final String type; 38 | final Map value; 39 | 40 | JsonMessage.fromJSON(Map json) : 41 | type = json['type'], 42 | value = json['value']; 43 | } 44 | -------------------------------------------------------------------------------- /lib/driver/playlist_manager.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:Spogit/driver/driver_request.dart'; 6 | import 'package:Spogit/driver/js_communication.dart'; 7 | import 'package:Spogit/json/album_full.dart'; 8 | import 'package:Spogit/json/playlist_full.dart'; 9 | import 'package:Spogit/json/playlist_simplified.dart'; 10 | import 'package:Spogit/json/track_full.dart'; 11 | import 'package:Spogit/json/track_simplified.dart'; 12 | import 'package:Spogit/utility.dart'; 13 | import 'package:http/http.dart'; 14 | import 'package:logging/logging.dart'; 15 | import 'package:webdriver/sync_io.dart'; 16 | 17 | class PlaylistManager { 18 | final log = Logger('PlaylistManager'); 19 | 20 | final WebDriver _driver; 21 | final RequestManager _requestManager; 22 | 23 | static const apiBase = 'https://api.spotify.com/v1'; 24 | 25 | String get rootlistUrl => 26 | 'https://spclient.wg.spotify.com/playlist/v2/user/${_requestManager.personalData.id}/rootlist'; 27 | 28 | String get apiUrl => '$apiBase/users/${_requestManager.personalData.id}'; 29 | 30 | PlaylistManager._(this._driver, this._requestManager); 31 | 32 | static Future createPlaylistManager(WebDriver driver, 33 | RequestManager requestManager, JSCommunication communication) async => 34 | PlaylistManager._(driver, requestManager); 35 | 36 | /// Gets all of a user's playlist IDs and their corresponding snapshot IDs. 37 | Future> getPlaylistSnapshots() => DriverRequest( 38 | method: RequestMethod.Get, 39 | token: _requestManager.authToken, 40 | uri: Uri.parse('$apiUrl/playlists')) 41 | .sendPaging( 42 | (map) => PlaylistSimplified.jsonConverter(map), 43 | all: true) 44 | .then((response) { 45 | var res = {}; 46 | for (var item in response) { 47 | res[item.id] = item.snapshotId; 48 | } 49 | return res; 50 | }); 51 | 52 | /// Gets the ETag of the base revision to detect if any playlist order has 53 | /// changed yet. 54 | Future baseRevisionETag() async { 55 | var response = await DriverRequest( 56 | method: RequestMethod.Head, 57 | token: _requestManager.authToken, 58 | uri: Uri.parse(rootlistUrl).replace(queryParameters: { 59 | 'decorate': 'revision,length,attributes,timestamp,owner', 60 | 'market': 'from_token' 61 | }), 62 | ).send(); 63 | 64 | return response.headers['etag']; 65 | } 66 | 67 | /// Gets a revision of the users' Spotify playlist data. 68 | Future analyzeBaseRevision() async { 69 | var response = await DriverRequest( 70 | method: RequestMethod.Get, 71 | token: _requestManager.authToken, 72 | uri: Uri.parse(rootlistUrl).replace(queryParameters: { 73 | 'decorate': 'revision,length,attributes,timestamp,owner', 74 | 'market': 'from_token', 75 | }), 76 | ).send(); 77 | 78 | if (response.statusCode != 200) { 79 | throw 'Status ${response.statusCode}: ${response.json['error']['message']}'; 80 | } 81 | 82 | return BaseRevision.fromJson(response.json); 83 | } 84 | 85 | /// Makes a request without the boilerplate. [makeRequest] should return a 86 | /// response via something like [DriverRequest#send()]. If [useBase] is true, 87 | /// [#analyzeBaseRevision()] is invoked and given as an argument to 88 | /// [makeRequest]. If false, null is given as an argument. Parsed JSON is 89 | /// returned as a response. 90 | Future> basedRequest( 91 | FutureOr Function(BaseRevision) makeRequest, 92 | {bool useBase = true, 93 | bool throwOnError = true}) async { 94 | var response = 95 | await makeRequest(useBase ? await analyzeBaseRevision() : null); 96 | 97 | if (response.statusCode >= 300) { 98 | if (!throwOnError) { 99 | return null; 100 | } 101 | 102 | try { 103 | throw 'Status ${response.statusCode}: ${response.body}'; 104 | } catch (e, s) { 105 | log.severe('Bruh moment', e, s); 106 | return null; 107 | } 108 | } 109 | 110 | return response.json; 111 | } 112 | 113 | /// Gets an album by its ID. 114 | /// Returns an Album JSON object. 115 | ///

See [Get an Album](https://developer.spotify.com/documentation/web-api/reference/albums/get-album/) 116 | Future getAlbum(String id) => basedRequest( 117 | (_) => DriverRequest( 118 | method: RequestMethod.Get, 119 | uri: Uri.parse('$apiBase/albums/$id') 120 | .replace(queryParameters: {'id': id}), 121 | token: _requestManager.authToken) 122 | .send(), 123 | useBase: false) 124 | .then((json) => AlbumFull.fromJson(json ?? {})); 125 | 126 | /// Gets an album's tracks by its ID. If [all] is true, it will get all 127 | /// tracks. If false, it will only return the first 50. 128 | ///

See [Get an Album](https://developer.spotify.com/documentation/web-api/reference/albums/get-album/) 129 | Future> getAlbumTracks(String id, [bool all = false]) => 130 | DriverRequest( 131 | method: RequestMethod.Get, 132 | uri: Uri.parse('$apiBase/albums/$id/tracks') 133 | .replace(queryParameters: {'id': id}), 134 | token: _requestManager.authToken) 135 | .sendPaging(TrackSimplified.jsonConverter, all: all); 136 | 137 | /// Moves the given playlist ID [moving] to a location. If [toGroup] is set, 138 | /// it is the group ID to move the playlist in. When that is set, [offset] is 139 | /// the relative offset of this set group. If neither of these are set, 140 | /// [absolutePosition] is the absolute position in the flat list where it is 141 | /// created in. 142 | ///

This is not available in the public API and is located in the URL 143 | /// `https://spclient.wg.spotify.com/playlist/v2/user/USER_ID/rootlist/changes` 144 | Future> movePlaylist(String moving, 145 | {String toGroup, int offset = 0, int absolutePosition}) async { 146 | await awaitSleep(Duration(milliseconds: 250)); // TODO: Proper rate limiting system!!! 147 | return basedRequest((baseRevision) { 148 | var movingElement = baseRevision.getElement(moving); 149 | 150 | absolutePosition ??= 151 | (baseRevision.getIndexOf(toGroup)?.add(1) ?? 0) + offset; 152 | var fromIndex = movingElement.index; 153 | 154 | return DriverRequest( 155 | uri: Uri.parse('$rootlistUrl/changes'), 156 | token: _requestManager.authToken, 157 | body: { 158 | 'baseRevision': baseRevision.revision, 159 | 'deltas': [ 160 | { 161 | 'ops': [ 162 | { 163 | 'kind': 'MOV', 164 | 'mov': { 165 | 'fromIndex': fromIndex, 166 | 'toIndex': absolutePosition, 167 | 'length': movingElement.moveCount, 168 | } 169 | } 170 | ], 171 | 'info': { 172 | 'source': {'client': 'WEBPLAYER'} 173 | } 174 | } 175 | ] 176 | }, 177 | ).send(); 178 | }); 179 | } 180 | 181 | /// Creates a folder with the given [name]. If [toGroup] is set, it will be 182 | /// moved to the ID of the given group. When that is set, [offset] is the 183 | /// relative offset of this set group. If neither of these are set, 184 | /// [absolutePosition] is the absolute position in the flat list where it is 185 | /// created in. 186 | ///

This is not available in the public API and is located in the URL 187 | /// `https://spclient.wg.spotify.com/playlist/v2/user/USER_ID/rootlist/changes` 188 | Future> createFolder(String name, 189 | {String toGroup, int offset = 0, int absolutePosition}) async { 190 | var id = '${randomHex(12)}000'; 191 | 192 | return basedRequest((baseRevision) { 193 | absolutePosition ??= 194 | (baseRevision.getIndexOf(toGroup)?.add(1) ?? 0) + offset; 195 | return DriverRequest( 196 | uri: Uri.parse('$rootlistUrl/changes'), 197 | token: _requestManager.authToken, 198 | body: { 199 | 'baseRevision': '${baseRevision.revision}', 200 | 'deltas': [ 201 | { 202 | 'ops': [ 203 | { 204 | 'kind': 'ADD', 205 | 'add': { 206 | 'fromIndex': absolutePosition, 207 | 'items': [ 208 | { 209 | 'attributes': {'timestamp': now}, 210 | 'uri': 211 | 'spotify:start-group:$id:${Uri.encodeComponent(name)}' 212 | } 213 | ] 214 | } 215 | }, 216 | { 217 | 'kind': 'ADD', 218 | 'add': { 219 | 'fromIndex': absolutePosition + 1, 220 | 'items': [ 221 | { 222 | 'attributes': {'timestamp': now}, 223 | 'uri': 'spotify:end-group:$id' 224 | } 225 | ] 226 | } 227 | } 228 | ], 229 | 'info': { 230 | 'source': {'client': 'WEBPLAYER'} 231 | } 232 | } 233 | ] 234 | }, 235 | ).send(); 236 | }).then((result) => { 237 | ...result, 238 | ...{'id': id} 239 | }); 240 | } 241 | 242 | /// Adds a list of tracks to a given [playlist] ID, by their ids [trackIds]. 243 | ///

See [Add Items to a Playlist](https://developer.spotify.com/documentation/web-api/reference/playlists/add-tracks-to-playlist/) 244 | Future> addTracks( 245 | String playlist, List trackIds) { 246 | return DriverRequest( 247 | uri: Uri.parse('$apiBase/playlists/${playlist.parseId}/tracks'), 248 | token: _requestManager.authToken, 249 | body: { 250 | 'uris': trackIds.map((str) => 'spotify:track:${str.parseId}').toList() 251 | }, 252 | ).send().then((res) => res.json); 253 | } 254 | 255 | /// Creates a playlist with the given [name], and optional [description]. 256 | ///

See [Create a Playlist](https://developer.spotify.com/documentation/web-api/reference/playlists/create-playlist/) 257 | Future> createPlaylist(String name, 258 | [String description = '']) async { 259 | await awaitSleep(Duration(milliseconds: 250)); // TODO: Proper rate limiting system!!! 260 | return basedRequest( 261 | (_) => DriverRequest( 262 | uri: Uri.parse('$apiUrl/playlists'), 263 | token: _requestManager.authToken, 264 | body: { 265 | 'name': name, 266 | 'description': description, 267 | }, 268 | ).send(), 269 | useBase: false); 270 | } 271 | 272 | /// Gets a playlist by its [id]. 273 | ///

See [Get a Playlist](https://developer.spotify.com/documentation/web-api/reference/playlists/get-playlist/) 274 | Future getPlaylistInfo(String id, {bool throwOnError = true}) { 275 | return basedRequest( 276 | (_) => DriverRequest( 277 | method: RequestMethod.Get, 278 | uri: Uri.parse('$apiBase/playlists/${id.parseId}') 279 | .replace(queryParameters: { 280 | 'type': 'track,episode', 281 | 'market': 'from_token', 282 | }), 283 | token: _requestManager.authToken, 284 | ).send(), 285 | useBase: false, 286 | throwOnError: throwOnError) 287 | .then(PlaylistFull.jsonConverter); 288 | } 289 | 290 | /// Removes tracks from the given [playlist] ID. [trackIds] should contain a 291 | /// list of track IDs to remove. These do not have to be parsed IDs. 292 | Future> removeTracks( 293 | String playlist, List trackIds) { 294 | trackIds = trackIds.map((str) => str.parseId).toList(); 295 | 296 | return getPlaylistInfo(playlist).then((info) { 297 | var items = info.tracks.items; 298 | 299 | var ids = items 300 | .map((track) => track.track.id) 301 | .map((id) => id.parseId) 302 | .toList() 303 | .asMap() 304 | ..removeWhere((i, id) => !trackIds.contains(id)); 305 | 306 | return DriverRequest( 307 | method: RequestMethod.Delete, 308 | uri: Uri.parse('$apiBase/playlists/${playlist.parseId}'), 309 | token: _requestManager.authToken, 310 | body: { 311 | 'tracks': [ 312 | for (var index in ids.keys) 313 | { 314 | { 315 | 'positions': [index], 316 | 'uri': 'spotify:track:${ids[index]}' 317 | }, 318 | } 319 | ] 320 | }, 321 | ).send().then((res) => res.json); 322 | }); 323 | } 324 | 325 | /// Uploads the given [file] as the playlist cover to the given [playlist] ID. 326 | /// 327 | /// TODO: As of 5/9/2020 the Spotify webapp's authorization token does NOT 328 | /// allow for this method, which is strange but nothing can be done about it. 329 | /// Possible fixes for this include adding a proper auth token from a normal 330 | /// app instead of using the webapp (Would require more setup for the end-user 331 | /// as desktop apps are not supported) or waiting until this ability is added 332 | /// to Spotify. 333 | ///

See [Upload a Custom Playlist Cover Image](https://developer.spotify.com/documentation/web-api/reference/playlists/upload-custom-playlist-cover/) 334 | Future uploadCover(File file, String playlist) async { 335 | if (!(await file.exists())) { 336 | return; 337 | } 338 | 339 | log.warning( 340 | 'Unless an application authentication token is being used, setting the upload cover will not work.'); 341 | 342 | return basedRequest( 343 | (_) async => DriverRequest( 344 | uri: Uri.parse('$apiBase/playlists/${playlist.parseId}/images'), 345 | method: RequestMethod.Put, 346 | token: _requestManager.authToken, 347 | headers: { 348 | 'Content-Type': 'image/jpeg', 349 | }, 350 | body: base64Encode(await file.readAsBytes()), 351 | ).send(), 352 | useBase: false); 353 | } 354 | 355 | /// Gets information on a single track by its [id]. 356 | ///

See [Get a Track](https://developer.spotify.com/documentation/web-api/reference/tracks/get-track/) 357 | Future getTrack(String id, {bool throwOnError = true}) => 358 | basedRequest( 359 | (_) => DriverRequest( 360 | method: RequestMethod.Get, 361 | uri: Uri.parse('$apiBase/tracks/${id.parseId}'), 362 | token: _requestManager.authToken, 363 | ).send(), 364 | useBase: false, 365 | throwOnError: throwOnError) 366 | .then(TrackFull.jsonConverter); 367 | 368 | /// Gets information on multiple tracks by their [ids]. 369 | ///

See [Get Several Tracks](https://developer.spotify.com/documentation/web-api/reference/tracks/get-several-tracks/) 370 | Future> getTracks(List ids, 371 | {bool throwOnError = true}) => 372 | basedRequest( 373 | (_) => DriverRequest( 374 | method: RequestMethod.Get, 375 | uri: Uri.parse('$apiBase/tracks'), 376 | token: _requestManager.authToken, 377 | body: { 378 | 'ids': ids.map((id) => id.parseId).join(','), 379 | }).send(), 380 | useBase: false, 381 | throwOnError: throwOnError) 382 | .then((json) => json['tracks'].map(TrackFull.jsonConverter).toList()); 383 | } 384 | 385 | /// A flat, direct representation of the fetched base revision 386 | class BaseRevision { 387 | /// The Spotify-generated revision ID 388 | final String revision; 389 | 390 | /// A flat list of [RevisionElements] in the current revision 391 | final List elements; 392 | 393 | BaseRevision.fromJson(Map json) 394 | : revision = json['revision'], 395 | elements = parseElements(jsonify(json['contents'])); 396 | 397 | static List parseElements(Map json) { 398 | var children = analyzeChildren(json); 399 | var meta = json['metaItems']; 400 | if (meta == null) { 401 | return const []; 402 | } 403 | 404 | return List.from(json['items'].asMap()?.map((i, elem) { 405 | var metaVal = jsonify(meta[i]); 406 | var itemVal = elem; 407 | 408 | var attributes = metaVal.isNotEmpty 409 | ? jsonify({...metaVal['attributes'], ...itemVal['attributes']}) 410 | : itemVal['attributes']; 411 | metaVal.remove('attributes'); 412 | itemVal.remove('attributes'); 413 | 414 | var uri = itemVal['uri'] as String; 415 | var id = uri.parseId; 416 | var type = uri.parseElementType; 417 | var name = type == ElementType.FolderStart 418 | ? Uri.decodeComponent(uri.split(':')[3].replaceAll('+', ' ')) 419 | : null; 420 | 421 | return MapEntry( 422 | i, 423 | RevisionElement.fromJson( 424 | i, 425 | type == ElementType.FolderStart ? children[id] : 0, 426 | jsonify({...metaVal, ...itemVal, ...attributes}), 427 | name: name, 428 | id: id, 429 | type: type)); 430 | })?.values ?? 431 | {}); 432 | } 433 | 434 | static Map analyzeChildren(Map json) { 435 | // A list of items like [start-group, myspotifyid] and [end-group, someid] to be parsed 436 | var ids = List>.from((json['items'] ?? const []) 437 | .map((entry) => entry['uri'].split(':').skip(1).take(2).toList())); 438 | 439 | var result = {}; 440 | 441 | var currentlyIn = {}; 442 | 443 | for (var value in ids) { 444 | var id = value[1]; 445 | var type = value[0]; // playlist, start-group, end-group 446 | 447 | for (var curr in currentlyIn.keys) { 448 | currentlyIn[curr]++; 449 | } 450 | 451 | if (type == 'start-group') { 452 | currentlyIn[id] = 0; 453 | } else if (type == 'end-group') { 454 | result[id] = currentlyIn.remove(id) - 1; 455 | } 456 | } 457 | 458 | result.addAll(currentlyIn); 459 | 460 | return result; 461 | } 462 | 463 | RevisionElement getElement(String id) { 464 | id = id?.parseId; 465 | return elements.firstWhere((revision) => revision.id == id, 466 | orElse: () => null); 467 | } 468 | 469 | int getIndexOf(String id) => getElement(id)?.index; 470 | 471 | int getTrackCountOf(String id) => getElement(id)?.length; 472 | 473 | int getHash({String id, RevisionElement element}) { 474 | if ((element ??= getElement(id)) == null) { 475 | return 0; 476 | } 477 | 478 | var totalHash = 0; 479 | 480 | void append(int number) { 481 | var breaking = (number.bitLength / 8).ceil(); 482 | for (var i = 0; i < breaking; i++) { 483 | totalHash += number & 0xFF; 484 | number >>= 8; 485 | totalHash <<= 8; 486 | } 487 | } 488 | 489 | for (var i = element.index; i < element.index + element.moveCount; i++) { 490 | var curr = elements[i]; 491 | switch (curr.type) { 492 | case ElementType.Playlist: 493 | append(0x00); 494 | append(curr.length); 495 | append(0x00); 496 | append(curr.id.hashCode); 497 | append(0x00); 498 | break; 499 | case ElementType.FolderStart: 500 | append(0x01); 501 | break; 502 | case ElementType.FolderEnd: 503 | append(0x02); 504 | break; 505 | } 506 | } 507 | 508 | return totalHash; 509 | } 510 | 511 | @override 512 | String toString() { 513 | return 'BaseRevision{revision: $revision, elements: $elements}'; 514 | } 515 | } 516 | 517 | class RevisionElement { 518 | /// The index of the revision element, used for moving elements. 519 | final int index; 520 | 521 | /// (Present if folder) The name of the element. 522 | final String name; 523 | 524 | /// (Present if playlist) The amount of tracks in a playlist. 525 | final int length; 526 | 527 | /// The amount of children this item has, not including itself (e.g. Empty 528 | /// folders will be 0). 529 | final int children; 530 | 531 | /// The timestamp created. 532 | final int timestamp; 533 | 534 | /// (Present if playlist) If the playlist is publicly available. 535 | final bool public; 536 | 537 | /// The parsed ID of the current element. If the raw uri was 538 | /// `spotify:playlist:whatever` this value would be `whatever`. 539 | final String id; 540 | 541 | /// The [ElementType] of the current element. 542 | final ElementType type; 543 | 544 | /// Gets the amount of items to move if this were to be moved. On playlists 545 | /// this is one, for groups/folders this is the [children] plus two. This 546 | /// two is for the group start and end to move as well. 547 | int get moveCount => type == ElementType.Playlist ? 1 : children + 2; 548 | 549 | RevisionElement.fromJson(this.index, this.children, Map json, 550 | {String name, String id, ElementType type}) 551 | : name = name ?? json['name'], 552 | length = json['length'], 553 | timestamp = (json['timestamp'] as String).parseInt(), 554 | public = json['public'] ?? false, 555 | id = id ?? (json['uri'] as String).parseId, 556 | type = type ?? (json['uri'] as String).parseElementType; 557 | 558 | @override 559 | bool operator ==(Object other) => 560 | identical(this, other) || 561 | other is RevisionElement && 562 | runtimeType == other.runtimeType && 563 | index == other.index && 564 | length == other.length && 565 | children == other.children && 566 | timestamp == other.timestamp && 567 | public == other.public && 568 | id == other.id && 569 | type == other.type; 570 | 571 | @override 572 | int get hashCode => 573 | index.hashCode ^ 574 | length.hashCode ^ 575 | children.hashCode ^ 576 | timestamp.hashCode ^ 577 | public.hashCode ^ 578 | id.hashCode ^ 579 | type.hashCode; 580 | 581 | @override 582 | String toString() { 583 | return 'RevisionElement{index: $index, name: $name, length: $length, children: $children, timestamp: $timestamp, public: $public, id: $id, type: $type}'; 584 | } 585 | } 586 | 587 | enum ElementType { FolderStart, FolderEnd, Playlist } 588 | 589 | extension ParsingUtils on String { 590 | String splitOrThis(Pattern pattern, int index) { 591 | if (!contains(pattern)) { 592 | return this; 593 | } 594 | 595 | var arr = split(pattern); 596 | return index >= arr.length ? this : arr[index]; 597 | } 598 | 599 | String get parseId => splitOrThis(':', 2); 600 | 601 | ElementType get parseElementType => const { 602 | 'playlist': ElementType.Playlist, 603 | 'start-group': ElementType.FolderStart, 604 | 'end-group': ElementType.FolderEnd 605 | }[splitOrThis(':', 1)]; 606 | } 607 | -------------------------------------------------------------------------------- /lib/driver_utility.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:webdriver/sync_io.dart'; 4 | import 'package:Spogit/utility.dart'; 5 | 6 | Future getElement(WebDriver driver, By by, 7 | {int duration = 5000, int checkInterval = 100}) async { 8 | var elements = []; 9 | do { 10 | try { 11 | elements = await driver.findElements(by); 12 | if (elements.isNotEmpty) return elements.first; 13 | } catch (_) {} 14 | await awaitSleep(Duration(milliseconds: checkInterval)); 15 | duration -= checkInterval; 16 | } while (elements.isEmpty && duration > 0); 17 | return elements.safeFirst; 18 | } 19 | -------------------------------------------------------------------------------- /lib/fs/local_storage.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:Spogit/utility.dart'; 5 | 6 | abstract class LocalStorage { 7 | final File _file; 8 | final Map _json; 9 | bool modified = false; 10 | 11 | LocalStorage(this._file) : _json = {...tryJsonDecode(_file.tryReadSync())}; 12 | 13 | void saveFile() { 14 | if (modified) { 15 | modified = false; 16 | _file.tryCreateSync(); 17 | _file.writeAsStringSync(jsonEncode(_json)); 18 | } 19 | } 20 | 21 | dynamic operator [](key) { 22 | return _json[key]; 23 | } 24 | 25 | void operator []=(key, value) { 26 | if (_json[key] != value) { 27 | modified = true; 28 | _json[key] = value; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/fs/playlist.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:Spogit/Spogit.dart'; 6 | import 'package:Spogit/cache/id/id_resource_manager.dart'; 7 | import 'package:Spogit/driver/playlist_manager.dart'; 8 | import 'package:Spogit/fs/local_storage.dart'; 9 | import 'package:Spogit/json/album_simplified.dart'; 10 | import 'package:Spogit/json/playlist_full.dart'; 11 | import 'package:Spogit/markdown/readme.dart'; 12 | import 'package:Spogit/markdown/table_generator.dart'; 13 | import 'package:Spogit/utility.dart'; 14 | import 'package:http/http.dart' as http; 15 | 16 | class SpogitRoot extends SpotifyContainer { 17 | final RootLocal rootLocal; 18 | final File meta; 19 | final File coverImage; 20 | final File readme; 21 | 22 | @override 23 | final Spogit spogit; 24 | 25 | @override 26 | final Directory root; 27 | 28 | @override 29 | SpotifyContainer get parent => null; 30 | 31 | List _children; 32 | 33 | @override 34 | List get children => _children ??= _traverseDir(root, null); 35 | 36 | SpogitRoot(this.spogit, this.root, 37 | {bool creating = false, List tracking = const []}) 38 | : rootLocal = RootLocal([root, 'local'].file), 39 | meta = [root, 'meta.json'].file..tryCreateSync(), 40 | coverImage = [root, 'cover.jpg'].file, 41 | readme = [root, 'README.md'].file { 42 | children; 43 | if (creating) { 44 | 'local' >> [root, '.gitignore']; 45 | 46 | rootLocal 47 | ..id = randomHex(16) 48 | ..tracking = tracking; 49 | } 50 | } 51 | 52 | bool get isValid => meta.existsSync(); 53 | 54 | List _traverseDir(Directory dir, SpotifyFolder parent) => dir 55 | .listSync() 56 | .whereType() 57 | .where((dir) => !dir.uri.realName.startsWith('.')) 58 | .map((dir) { 59 | var name = dir.uri.realName; 60 | if (dir.isPlaylist) { 61 | return SpotifyPlaylist(spogit, unescapeSlash(name), dir.parent, parent); 62 | } else { 63 | final folder = SpotifyFolder(unescapeSlash(name), dir.parent, parent); 64 | folder.children = _traverseDir(dir, folder); 65 | return folder; 66 | } 67 | }).toList(); 68 | 69 | Future save() async { 70 | rootLocal.saveFile(); 71 | var images = []; 72 | 73 | for (var playlist in _children ?? const []) { 74 | await playlist.save(); 75 | images.add(playlist.readmeSnippet()); 76 | } 77 | 78 | var parsed = Readme.parse(await readme.tryRead()) 79 | ..title = root.uri.realName 80 | ..content = TableGenerator(images).generate(); 81 | parsed.create() >> readme; 82 | } 83 | 84 | String treeString() { 85 | var out = ''; 86 | for (var child in children) { 87 | out += '${child.treeString(0)}\n'; 88 | } 89 | return out; 90 | } 91 | 92 | int getSongCount() => children.map((child) => child.getSongCount()).reduce((a, b) => a + b); 93 | 94 | @override 95 | String toString() { 96 | return 'SpogitRoot{root: $root, meta: $meta, coverImage: $coverImage, _playlists: $_children}'; 97 | } 98 | } 99 | 100 | /// Information in the `local` file in the stored Root with an identifier and 101 | /// the playlists/folders used. All but the ID are mutable. 102 | class RootLocal extends LocalStorage { 103 | RootLocal(File file) : super(file); 104 | 105 | String _id; 106 | 107 | /// The randomized, local ID used to identify this. 108 | String get id => _id ??= this['id']; 109 | 110 | set id(String value) => _id = this['id'] = value; 111 | 112 | List _tracking; 113 | 114 | /// The IDs of the things that are being tracked. 115 | List get tracking => _tracking ??= 116 | List.from(this['tracking'] ?? const [], growable: false); 117 | 118 | set tracking(List value) => _tracking = this['tracking'] = value; 119 | 120 | Map _snapshotIds; 121 | 122 | /// A map of the playlist IDs and their snapshots, used for change tracking. 123 | Map get snapshotIds => 124 | _snapshotIds ??= Map.unmodifiable(this['snapshotIds'] ?? const {}); 125 | 126 | set snapshotIds(Map value) => 127 | _snapshotIds = this['snapshotIds'] = value; 128 | 129 | String _revision; 130 | 131 | /// The ETag of the last base revision used. 132 | String get revision => _revision ??= this['revision']; 133 | 134 | set revision(String value) => _revision = this['revision'] = value; 135 | } 136 | 137 | class SpotifyPlaylist extends Mappable { 138 | final Spogit _spogit; 139 | final File coverImage; 140 | final File _songsFile; 141 | final File _meta; 142 | 143 | String _imageUrl; 144 | 145 | set imageUrl(String url) { 146 | if (url != null && url != _imageUrl) { 147 | _imageUrl = url; 148 | imageChanged = true; 149 | } 150 | } 151 | 152 | bool imageChanged = false; 153 | int songsHash = 0; 154 | int metaHash = 0; 155 | 156 | @override 157 | final SpotifyFolder parent; 158 | 159 | Map _metaJson; 160 | 161 | Map get meta => 162 | _metaJson ??= {...tryJsonDecode(_meta.readAsStringSync())}; 163 | 164 | @override 165 | String get name => _metaJson['name']; 166 | 167 | set name(String value) => _metaJson['name'] = value; 168 | 169 | String get description => _metaJson['description']; 170 | 171 | set description(String value) => _metaJson['description'] = value; 172 | 173 | List _songs; 174 | 175 | List get songs => _songs ??= readSongs(); 176 | 177 | set songs(List songs) => _songs = songs; 178 | 179 | /// The [name] is the name of the playlist. The [parentDirectory] is the 180 | /// filesystem directory of what this playlist's folder will be contained in. 181 | /// The [parentFolder] is the [SpotifyFolder] of the parent, this may be null. 182 | SpotifyPlaylist(this._spogit, String name, Directory parentDirectory, 183 | [SpotifyFolder parentFolder]) 184 | : parent = parentFolder, 185 | coverImage = [parentDirectory, name, 'cover.jpg'].file, 186 | _meta = [parentDirectory, name, 'meta.json'].file 187 | ..createSync(recursive: true), 188 | _songsFile = [parentDirectory, name, 'songs.md'].file 189 | ..createSync(recursive: true), 190 | super([parentDirectory, name].directory) { 191 | meta; 192 | } 193 | 194 | List readSongs() { 195 | var read = Readme.parse(_songsFile.tryReadSync()); 196 | return read.content 197 | .split('---') 198 | .where((line) => line.trim().isNotEmpty) 199 | .map((line) => SpotifySong.fromLine(_spogit, line)) 200 | .notNull() 201 | .toList(); 202 | } 203 | 204 | @override 205 | int getSongCount() => songs.length; 206 | 207 | @override 208 | Future save() async { 209 | await super.save(); 210 | 211 | var currMetaHash = _metaJson?.customHash; 212 | if (metaHash == 0 || metaHash != currMetaHash) { 213 | metaHash = currMetaHash; 214 | await _meta.tryCreate(); 215 | await _meta.writeAsString(jsonEncode(meta)); 216 | } else if (!_meta.existsSync()) { 217 | _meta.createSync(); 218 | } 219 | 220 | var currSongsHash = _songs?.customHash; 221 | if (songsHash == 0 || songsHash != currSongsHash) { 222 | songsHash = currSongsHash; 223 | await _songsFile.tryCreate(); 224 | 225 | Readme.createTitled( 226 | title: name, 227 | description: description, 228 | content: 229 | (await songs.aMap((song) async => await song.toLine())) 230 | .join('\n\n')) 231 | .create() >> 232 | _songsFile; 233 | } else if (!_songsFile.existsSync()) { 234 | _songsFile.createSync(); 235 | } 236 | 237 | if (imageChanged) { 238 | imageChanged = false; 239 | if (_imageUrl != null) { 240 | await coverImage.writeAsBytes( 241 | await http.get(_imageUrl).then((res) => res.bodyBytes)); 242 | } 243 | } 244 | } 245 | 246 | @override 247 | String treeString([int depth = 0]) => '${' ' * depth} $name #$spotifyId'; 248 | 249 | // TODO: Change songs.md to the README when description and whatnot is added maybe 250 | @override 251 | String readmeSnippet() => 252 | '
$name
'; 253 | 254 | @override 255 | String toString() => 256 | 'SpotifyPlaylist{root: ${root.path}, meta: $meta, songs: $songs}'; 257 | } 258 | 259 | class SpotifyFolder extends Mappable with SpotifyContainer { 260 | File readme; 261 | 262 | @override 263 | String get name => root.uri.realName; 264 | 265 | @override 266 | final SpotifyContainer parent; 267 | 268 | @override 269 | List children; 270 | 271 | SpotifyFolder(String name, Directory parentDirectory, this.parent, 272 | [List children]) 273 | : children = children ?? [], 274 | super([parentDirectory, name].directory) { 275 | readme = [root, 'README.md'].file; 276 | } 277 | 278 | @override 279 | int getSongCount() => children.map((child) => child.getSongCount()).reduce((a, b) => a + b); 280 | 281 | /// Folder image: https://rubbaboy.me/images/uuy0w5i 282 | /// Empty playlist image: https://rubbaboy.me/images/d4w3yqc 283 | @override 284 | String readmeSnippet() => 285 | '
$name
'; 286 | 287 | @override 288 | Future save() async { 289 | await super.save(); 290 | 291 | var images = []; 292 | 293 | for (var child in children) { 294 | await child.save(); 295 | images.add(child.readmeSnippet()); 296 | } 297 | 298 | var str = await readme.tryRead(); 299 | 300 | var desc = str != null ? descriptionRegex.firstMatch(str)?.group(1) : null; 301 | 302 | Readme.createTitled( 303 | title: name, 304 | description: desc, 305 | content: TableGenerator(images).generate()) 306 | .create() >> 307 | readme; 308 | } 309 | 310 | @override 311 | String treeString([int depth = 0]) { 312 | var out = '${' ' * depth} $name #$spotifyId'; 313 | for (var child in children) { 314 | out += '\n${child.treeString(depth + 1)}'; 315 | } 316 | return '$out'; 317 | } 318 | 319 | @override 320 | String toString() { 321 | return 'SpotifyFolder{root: $root, children: $children}'; 322 | } 323 | } 324 | 325 | class SpotifySong { 326 | /// The parsed track ID 327 | final String id; 328 | 329 | /// The track name 330 | final String trackName; 331 | 332 | /// The ID of the album 333 | String albumId; 334 | 335 | /// The simplified album. This may be null 336 | AlbumSimplified _album; 337 | 338 | /// Gets or retrieves the [AlbumSimplified] from the constructor or [albumId]. 339 | FutureOr get album => 340 | _album ?? 341 | spogit.albumResourceManager 342 | .getAlbumFromId(albumId) 343 | .then((full) => _album = full); 344 | 345 | Spogit spogit; 346 | String cachedLine; 347 | 348 | /// Creates a SpotifySong from a single track json element. 349 | SpotifySong.fromJson(this.spogit, PlaylistTrack playlistTrack) 350 | : id = playlistTrack.track?.id, 351 | trackName = playlistTrack.track?.name ?? 'Unknown', 352 | _album = playlistTrack.track?.album { 353 | albumId = _album?.id ?? ''; 354 | } 355 | 356 | /// The [id] should be the parsed track ID, and the [artistName] is the full 357 | /// `Song - Artist` unparsed line from an existing link. 358 | SpotifySong._create(this.spogit, this.trackName, this.id, this.albumId, 359 | [this.cachedLine]); 360 | 361 | /// Example of a song chunk: 362 | /// ``` 363 | /// 364 | /// ### [Lucid Dreams](https://open.spotify.com/go?uri=spotify:track:285pBltuF7vW8TeWk8hdRR) 365 | /// [Goodbye & Good Riddance](https://open.spotify.com/go?uri=spotify:track:6tkjU4Umpo79wwkgPMV3nZ) 366 | /// --- 367 | /// ``` 368 | factory SpotifySong.fromLine(Spogit spogit, String songChunk) { 369 | var allMatches = linkRegex.allMatches(songChunk); 370 | var matches = allMatches.iterator; 371 | if (allMatches.length != 2) { 372 | return null; 373 | } 374 | 375 | matches.moveNext(); 376 | var trackMatch = matches.current; 377 | matches.moveNext(); 378 | var albumMatch = matches.current; 379 | 380 | return SpotifySong._create(spogit, trackMatch.group(1), trackMatch.group(2), 381 | albumMatch.group(2), songChunk.trim()); 382 | } 383 | 384 | FutureOr toLine() async => id == null ? '' : await (() async { 385 | var fetchedAlbum = await album; 386 | return ''' 387 | 388 | 389 | ### [${await spogit.idResourceManager.getName(id, ResourceType.Track)}](https://open.spotify.com/go?uri=spotify:track:$id) 390 | [${fetchedAlbum?.name}](https://open.spotify.com/go?uri=spotify:album:$albumId) 391 | 392 | --- 393 | '''; 394 | })(); 395 | 396 | @override 397 | bool operator ==(Object other) => 398 | identical(this, other) || 399 | other is SpotifySong && 400 | runtimeType == other.runtimeType && 401 | id == other.id; 402 | 403 | @override 404 | int get hashCode => id.hashCode; 405 | 406 | @override 407 | String toString() { 408 | return 'SpotifySong{id: $id}'; 409 | } 410 | } 411 | 412 | abstract class Mappable extends LocalStorage { 413 | final Directory root; 414 | 415 | Mappable(this.root) 416 | : super([root, 'local'].file..createSync(recursive: true)); 417 | 418 | String get name; 419 | 420 | SpotifyContainer get parent; 421 | 422 | String _spotifyId; 423 | 424 | String get spotifyId => _spotifyId ??= this['id']; 425 | 426 | set spotifyId(String id) => this['id'] = id; 427 | 428 | int getSongCount(); 429 | 430 | Future save() async => saveFile(); 431 | 432 | String treeString([int depth = 0]); 433 | 434 | /// Creates a snippet of markdown to put in a README file displaying the 435 | /// current object in a list. This should be something like a cover image 436 | /// or similar, around 75px square. 437 | String readmeSnippet(); 438 | } 439 | 440 | extension MappableChecker on Directory { 441 | bool get isPlaylist => 442 | [this, 'meta.json'].file.existsSync() && 443 | [this, 'songs.md'].file.existsSync(); 444 | } 445 | 446 | /// An object that stores playlists and folders. 447 | abstract class SpotifyContainer { 448 | Spogit spogit; 449 | 450 | /// The directory holding any data related to this container, including children. 451 | Directory get root; 452 | 453 | /// The parent of the container 454 | SpotifyContainer get parent; 455 | 456 | /// A nested list of [Mappable]s in the container 457 | List get children; 458 | 459 | Mappable searchForId(String id) { 460 | id = id.parseId; 461 | Mappable traverse(Mappable mappable) { 462 | if (mappable.spotifyId == id) { 463 | return mappable; 464 | } 465 | 466 | if (mappable is SpotifyFolder) { 467 | for (var child in mappable.children) { 468 | var traversed = traverse(child); 469 | if (traversed != null) { 470 | return traversed; 471 | } 472 | } 473 | } 474 | return null; 475 | } 476 | 477 | for (var child in children) { 478 | var traversed = traverse(child); 479 | if (traversed != null) { 480 | return traversed; 481 | } 482 | } 483 | 484 | return null; 485 | } 486 | 487 | /// Creates a [SpotifyPlaylist] in the current container with the given name. 488 | SpotifyPlaylist addPlaylist(String name) => 489 | _createMappable(name, () => SpotifyPlaylist(spogit, name, root)); 490 | 491 | /// Creates a [SpotifyFolder] in the current container with the given name. 492 | SpotifyFolder addFolder(String name) => 493 | _createMappable(name, () => SpotifyFolder(name, root, this)); 494 | 495 | /// Replaces a given [SpotifyPlaylist] by an existing [id]. This ID should be 496 | /// directly in the current container and not in any child. If no direct child 497 | /// is found with the given ID, it is added to the end of the child list. 498 | SpotifyPlaylist replacePlaylist(String id) => 499 | _replaceMappable(id, (name) => SpotifyPlaylist(spogit, name, root)) 500 | ..spotifyId = id; 501 | 502 | /// Replaces a given [SpotifyFolder] by an existing [id]. This ID should be 503 | /// directly in the current container and not in any child. If no direct child 504 | /// is found with the given ID, it is added to the end of the child list. 505 | SpotifyFolder replaceFolder(String id) => 506 | _replaceMappable(id, (name) => SpotifyFolder(name, root, this)) 507 | ..spotifyId = id; 508 | 509 | T _createMappable( 510 | String name, T Function() createMappable) { 511 | var playlist = createMappable(); 512 | children?.add(playlist); 513 | return playlist; 514 | } 515 | 516 | T _replaceMappable( 517 | String id, T Function(String) createMappable) { 518 | var foundMappable = children.indexWhere((map) => map.spotifyId == id); 519 | if (foundMappable == -1) { 520 | var playlist = createMappable(null); 521 | children.add(playlist); 522 | return playlist; 523 | } else { 524 | var playlist = createMappable(children[foundMappable]?.name); 525 | children.setAll(foundMappable, [playlist]); 526 | return playlist; 527 | } 528 | } 529 | } 530 | 531 | extension IDUtils on List { 532 | void parseAll() => replaceRange(0, length, map((s) => s.parseId)); 533 | } 534 | 535 | String parseArtists(Map trackJson) { 536 | var artistNames = 537 | List.from(trackJson['artists'].map((data) => data['name'])); 538 | if (artistNames.isEmpty) { 539 | return 'Unknown'; 540 | } 541 | 542 | return artistNames.join(', '); 543 | } 544 | -------------------------------------------------------------------------------- /lib/git_hook.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | import 'package:Spogit/utility.dart'; 4 | 5 | class GitHook { 6 | 7 | final postCheckout = StreamController.broadcast(); 8 | 9 | Future listen() async { 10 | var server = await HttpServer.bind(InternetAddress.loopbackIPv4, 9082); 11 | print('Listening on localhost:${server.port}'); 12 | 13 | server.listen((request) async { 14 | var segments = request.requestedUri.pathSegments; 15 | var query = request.requestedUri.queryParameters; 16 | 17 | if (segments.safeFirst == 'post-checkout') { 18 | postCheckout.add(PostCheckoutData(query['prev'], query['new'], query['from-branch'] == '1', query['pwd'].directory)); 19 | } else { 20 | print('Unknown path "/${segments.join('/')}"'); 21 | } 22 | 23 | await request.response.close(); 24 | }); 25 | } 26 | } 27 | 28 | class PostCheckoutData { 29 | final String prevRef; 30 | final String newRef; 31 | final bool branchCheckout; 32 | final Directory workingDirectory; 33 | 34 | PostCheckoutData(this.prevRef, this.newRef, this.branchCheckout, this.workingDirectory); 35 | } 36 | -------------------------------------------------------------------------------- /lib/input_controller.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:Spogit/Spogit.dart'; 5 | import 'package:Spogit/change_watcher.dart'; 6 | import 'package:Spogit/driver/driver_api.dart'; 7 | import 'package:Spogit/driver/playlist_manager.dart'; 8 | import 'package:Spogit/git_hook.dart'; 9 | import 'package:Spogit/local_manager.dart'; 10 | import 'package:Spogit/utility.dart'; 11 | import 'package:logging/logging.dart'; 12 | 13 | class InputController { 14 | final log = Logger('InputController'); 15 | 16 | final Spogit spogit; 17 | final DriverAPI driverAPI; 18 | final LocalManager localManager; 19 | final ChangeWatcher changeWatcher; 20 | 21 | InputController(this.spogit, this.localManager) 22 | : driverAPI = spogit.driverAPI, 23 | changeWatcher = spogit.changeWatcher; 24 | 25 | void start(Directory path) { 26 | log.info('Listening for commands...'); 27 | stdin.transform(utf8.decoder).listen((line) async { 28 | var split = line.splitQuotes(); 29 | 30 | // add-remote 31 | var command = split.safeFirst; 32 | var args = split.skip(1).toList(); 33 | 34 | switch (command) { 35 | case 'help': 36 | case '?': 37 | print(''' 38 | === Command help === 39 | 40 | status 41 | Lists the linked repos and playlists 42 | 43 | list 44 | Lists your Spotify accounts' playlist and folder names and IDs. 45 | 46 | add-remote "My Demo" spotify:playlist:41fMgMIEZJLJjJ9xbzYar6 27345c6f477d000 47 | Adds a list of playlist or folder IDs to the local Spogit root with the given name. 48 | 49 | add-local "My Demo" 50 | Adds a local directory in the Spogit root to your Spotify account and begin tracking. Useful if git hooks are not working. 51 | 52 | ==='''); 53 | break; 54 | case 'ar': 55 | case 'add-remote': 56 | if (args.length < 2) { 57 | print( 58 | 'Please specify the name of the grouping and a list of root playlists/tracks to add from remote!'); 59 | print('Example usage (Playlist and a folder):'); 60 | print( 61 | '\tadd-remote "My Demo" spotify:playlist:41fMgMIEZJLJjJ9xbzYar6 27345c6f477d000'); 62 | return; 63 | } 64 | 65 | log.info('Adding remote!'); 66 | 67 | var name = args.first; 68 | 69 | var ids = 70 | args.skip(1).map((str) => ParsingUtils(str).parseId).toList(); 71 | 72 | var base = await driverAPI.playlistManager.analyzeBaseRevision(); 73 | var baseIds = base.elements.map((elem) => elem.id.parseId).toSet(); 74 | ids.removeWhere((id) => !baseIds.contains(id)); 75 | 76 | if (ids.isEmpty) { 77 | log.warning('Could not perform that action as no IDs were top-level.'); 78 | break; 79 | } 80 | 81 | log.info('Beginning the linking...'); 82 | 83 | changeWatcher.lock(); 84 | 85 | var local = LinkedPlaylist.fromRemote( 86 | spogit, 87 | localManager, 88 | spogit.spogitPath, 89 | name, 90 | await driverAPI.playlistManager.analyzeBaseRevision(), 91 | ids); 92 | localManager.addPlaylist(local); 93 | await local.initElement(); 94 | 95 | changeWatcher.unlock(); 96 | 97 | log.info('Processed ${local.root.getSongCount()} songs'); 98 | 99 | log.info('Completed linking!'); 100 | break; 101 | case 'al': 102 | case 'add-local': 103 | if (args.length != 1) { 104 | print( 105 | 'The arguments must be a single directory name as the Spogit child'); 106 | print('Example usage:'); 107 | print('\tadd-local "My Demo"'); 108 | break; 109 | } 110 | 111 | var addingPath = [path, args[0]].directoryRaw; 112 | print('Adding local repository ${addingPath.path}'); 113 | spogit.gitHook.postCheckout 114 | .add(PostCheckoutData('', '', false, addingPath)); 115 | break; 116 | case 'status': 117 | for (var value in localManager.linkedPlaylists) { 118 | print('\nSpogit/${value.root.root.uri.realName}:'); 119 | print(value.root.treeString()); 120 | } 121 | break; 122 | case 'list': 123 | print(''' 124 | 125 | Listing of all current Spotify tree data. 126 | Key: 127 | P - Playlist. ID starts with spotify:playlist 128 | S - Group start. ID starts with spotify:start-group 129 | E - Group end. ID starts with spotify:end-group 130 | 131 | '''); 132 | var base = await driverAPI.playlistManager.analyzeBaseRevision(); 133 | var depth = 0; 134 | var nameMap = {}; 135 | for (var element in base.elements) { 136 | String line(String type, [String name]) => 137 | '${' ' * depth} [$type] ${name ?? element.name} #${element.id}'; 138 | switch (element.type) { 139 | case ElementType.Playlist: 140 | print(line('P')); 141 | break; 142 | case ElementType.FolderStart: 143 | nameMap[element.id] = element.name; 144 | print(line('S')); 145 | depth++; 146 | break; 147 | case ElementType.FolderEnd: 148 | depth--; 149 | print(line('E', nameMap[element.id])); 150 | break; 151 | } 152 | } 153 | break; 154 | case 'save': 155 | print('Saving data from memory...'); 156 | 157 | if (args.isEmpty) { 158 | print('Please provide a list of IDs to pull.'); 159 | break; 160 | } 161 | 162 | for (var id in args) { 163 | var pulling = localManager.getFromAnyId(id); 164 | await pulling.initElement(); 165 | } 166 | 167 | break; 168 | default: 169 | print('Couldn\'t recognise command "$command"'); 170 | break; 171 | } 172 | print(''); 173 | }); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /lib/json/album_full.dart: -------------------------------------------------------------------------------- 1 | import 'package:Spogit/json/album_simplified.dart'; 2 | import 'package:Spogit/json/artist.dart'; 3 | import 'package:Spogit/json/image.dart'; 4 | import 'package:Spogit/json/paging.dart'; 5 | import 'package:Spogit/json/sub/external_ids.dart'; 6 | import 'package:Spogit/json/sub/external_url.dart'; 7 | import 'package:Spogit/json/track_simplified.dart'; 8 | 9 | class AlbumFull extends AlbumSimplified { 10 | List copyrights; 11 | ExternalIds externalIds; 12 | List genres; 13 | int popularity; 14 | Paging tracks; 15 | 16 | AlbumFull( 17 | {String albumType, 18 | List artists, 19 | List availableMarkets, 20 | this.copyrights, 21 | this.externalIds, 22 | ExternalUrls externalUrls, 23 | this.genres, 24 | String href, 25 | String id, 26 | List images, 27 | String name, 28 | this.popularity, 29 | String releaseDate, 30 | String releaseDatePrecision, 31 | this.tracks, 32 | String type, 33 | String uri}) 34 | : super( 35 | albumType: albumType, 36 | artists: artists, 37 | availableMarkets: availableMarkets, 38 | externalUrls: externalUrls, 39 | href: href, 40 | id: id, 41 | images: images, 42 | name: name, 43 | releaseDate: releaseDate, 44 | releaseDatePrecision: releaseDatePrecision, 45 | type: type, 46 | uri: uri); 47 | 48 | AlbumFull.fromJson(Map json) : super.fromJson(json) { 49 | if (json['copyrights'] != null) { 50 | copyrights = []; 51 | json['copyrights'].forEach((v) { 52 | copyrights.add(Copyrights.fromJson(v)); 53 | }); 54 | } 55 | externalIds = json['external_ids'] != null 56 | ? ExternalIds.fromJson(ExternalType.UPC, json['external_ids']) 57 | : null; 58 | genres = json['genres']?.cast(); 59 | popularity = json['popularity']; 60 | tracks = Paging.fromJson(json['tracks'] ?? {}, TrackSimplified.jsonConverter); 61 | } 62 | 63 | @override 64 | Map toJson() => { 65 | ...super.toJson(), 66 | if (copyrights != null) ...{ 67 | 'copyrights': copyrights.map((v) => v.toJson()).toList(), 68 | }, 69 | if (externalIds != null) ...{ 70 | 'external_ids': externalIds.toJson(), 71 | }, 72 | 'genres': genres, 73 | 'popularity': popularity, 74 | 'tracks': tracks, 75 | }; 76 | } 77 | 78 | class Copyrights { 79 | String text; 80 | String type; 81 | 82 | Copyrights({this.text, this.type}); 83 | 84 | Copyrights.fromJson(Map json) { 85 | text = json['text']; 86 | type = json['type']; 87 | } 88 | 89 | Map toJson() => { 90 | 'text': text, 91 | 'type': type, 92 | }; 93 | } 94 | -------------------------------------------------------------------------------- /lib/json/album_simplified.dart: -------------------------------------------------------------------------------- 1 | import 'package:Spogit/json/artist.dart'; 2 | import 'package:Spogit/json/image.dart'; 3 | import 'package:Spogit/json/json.dart'; 4 | import 'package:Spogit/json/sub/external_url.dart'; 5 | 6 | class AlbumSimplified with Jsonable { 7 | String albumType; 8 | List artists; 9 | List availableMarkets; 10 | ExternalUrls externalUrls; 11 | String href; 12 | String id; 13 | List images; 14 | String name; 15 | String releaseDate; 16 | String releaseDatePrecision; 17 | String type; 18 | String uri; 19 | 20 | AlbumSimplified( 21 | {this.albumType, 22 | this.artists, 23 | this.availableMarkets, 24 | this.externalUrls, 25 | this.href, 26 | this.id, 27 | this.images, 28 | this.name, 29 | this.releaseDate, 30 | this.releaseDatePrecision, 31 | this.type, 32 | this.uri}); 33 | 34 | AlbumSimplified.fromJson(Map json) { 35 | albumType = json['album_type']; 36 | if (json['artists'] != null) { 37 | artists = []; 38 | json['artists'].forEach((v) { 39 | artists.add(Artists.fromJson(v)); 40 | }); 41 | } 42 | availableMarkets = json['available_markets']?.cast(); 43 | externalUrls = json['external_urls'] != null 44 | ? ExternalUrls.fromJson(json['external_urls']) 45 | : null; 46 | href = json['href']; 47 | id = json['id']; 48 | if (json['images'] != null) { 49 | images = []; 50 | json['images'].forEach((v) { 51 | images.add(Images.fromJson(v)); 52 | }); 53 | } 54 | name = json['name']; 55 | releaseDate = json['release_date']; 56 | releaseDatePrecision = json['release_date_precision']; 57 | type = json['type']; 58 | uri = json['uri']; 59 | } 60 | 61 | @override 62 | Map toJson() => { 63 | 'album_type': albumType, 64 | if (artists != null) ...{ 65 | 'artists': artists.map((v) => v.toJson()).toList(), 66 | }, 67 | 'available_markets': availableMarkets, 68 | if (externalUrls != null) ...{ 69 | 'external_urls': externalUrls.toJson(), 70 | }, 71 | 'href': href, 72 | 'id': id, 73 | if (images != null) ...{ 74 | 'images': images.map((v) => v.toJson()).toList(), 75 | }, 76 | 'name': name, 77 | 'release_date': releaseDate, 78 | 'release_date_precision': releaseDatePrecision, 79 | 'type': type, 80 | 'uri': uri, 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /lib/json/artist.dart: -------------------------------------------------------------------------------- 1 | import 'package:Spogit/json/json.dart'; 2 | import 'package:Spogit/json/sub/external_url.dart'; 3 | 4 | class Artists with Jsonable { 5 | ExternalUrls externalUrls; 6 | String href; 7 | String id; 8 | String name; 9 | String type; 10 | String uri; 11 | 12 | Artists( 13 | {this.externalUrls, this.href, this.id, this.name, this.type, this.uri}); 14 | 15 | Artists.fromJson(Map json) { 16 | externalUrls = json['external_urls'] != null 17 | ? new ExternalUrls.fromJson(json['external_urls']) 18 | : null; 19 | href = json['href']; 20 | id = json['id']; 21 | name = json['name']; 22 | type = json['type']; 23 | uri = json['uri']; 24 | } 25 | 26 | @override 27 | Map toJson() { 28 | final Map data = new Map(); 29 | if (this.externalUrls != null) { 30 | data['external_urls'] = this.externalUrls.toJson(); 31 | } 32 | data['href'] = this.href; 33 | data['id'] = this.id; 34 | data['name'] = this.name; 35 | data['type'] = this.type; 36 | data['uri'] = this.uri; 37 | return data; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/json/image.dart: -------------------------------------------------------------------------------- 1 | import 'package:Spogit/json/json.dart'; 2 | 3 | class Images with Jsonable { 4 | int height; 5 | String url; 6 | int width; 7 | 8 | Images({this.height, this.url, this.width}); 9 | 10 | Images.fromJson(Map json) { 11 | height = json['height']; 12 | url = json['url']; 13 | width = json['width']; 14 | } 15 | 16 | @override 17 | Map toJson() { 18 | final Map data = new Map(); 19 | data['height'] = this.height; 20 | data['url'] = this.url; 21 | data['width'] = this.width; 22 | return data; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/json/json.dart: -------------------------------------------------------------------------------- 1 | mixin Jsonable { 2 | Map toJson(); 3 | } 4 | -------------------------------------------------------------------------------- /lib/json/paging.dart: -------------------------------------------------------------------------------- 1 | import 'package:Spogit/json/json.dart'; 2 | 3 | class Paging with Jsonable { 4 | final String href; 5 | final List items; 6 | final int limit; 7 | final String next; 8 | final int offset; 9 | final String previous; 10 | final int total; 11 | 12 | Paging(this.href, this.items, this.limit, this.next, this.offset, 13 | this.previous, this.total); 14 | 15 | Paging.fromJson(Map json, 16 | [T Function(Map) pagingConvert]) 17 | : href = json['href'], 18 | items = ((List> list) => pagingConvert == null 19 | ? list 20 | : list 21 | ?.map(pagingConvert) 22 | ?.toList() ?? const [])(List>.from(json['items'] ?? {})), 23 | limit = json['limit'], 24 | next = json['next'], 25 | offset = json['offset'], 26 | previous = json['previous'], 27 | total = json['total']; 28 | 29 | @override 30 | Map toJson() => { 31 | 'href': href, 32 | 'items': items.map((item) => item.toJson()).toList(), 33 | 'limit': limit, 34 | 'next': next, 35 | 'offset': offset, 36 | 'previous': previous, 37 | 'total': total, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /lib/json/playlist_full.dart: -------------------------------------------------------------------------------- 1 | import 'package:Spogit/json/artist.dart'; 2 | import 'package:Spogit/json/image.dart'; 3 | import 'package:Spogit/json/json.dart'; 4 | import 'package:Spogit/json/paging.dart'; 5 | import 'package:Spogit/json/playlist_simplified.dart'; 6 | import 'package:Spogit/json/sub/external_url.dart'; 7 | import 'package:Spogit/json/track_full.dart'; 8 | 9 | class PlaylistFull extends PlaylistSimplified> with Jsonable { 10 | String description; 11 | Followers followers; 12 | 13 | PlaylistFull( 14 | {bool collaborative, 15 | this.description, 16 | ExternalUrls externalUrls, 17 | this.followers, 18 | String href, 19 | String id, 20 | List images, 21 | String name, 22 | Artists owner, 23 | bool public, 24 | String snapshotId, 25 | Paging tracks, 26 | String type, 27 | String uri}) 28 | : super( 29 | collaborative: collaborative, 30 | externalUrls: externalUrls, 31 | href: href, 32 | id: id, 33 | images: images, 34 | name: name, 35 | owner: owner, 36 | public: public, 37 | snapshotId: snapshotId, 38 | tracks: tracks, 39 | type: type, 40 | uri: uri, 41 | ); 42 | 43 | static PlaylistFull jsonConverter(Map json) => 44 | json == null ? null : PlaylistFull.fromJson(json); 45 | 46 | PlaylistFull.fromJson(Map json) 47 | : description = json['description'], 48 | followers = json['followers'] != null 49 | ? Followers.fromJson(json['followers'] ?? {}) 50 | : null, 51 | super.fromJson(json); 52 | 53 | @override 54 | Map toJson() => { 55 | ...super.toJson(), 56 | 'description': description, 57 | if (followers != null) ...{ 58 | 'followers': followers.toJson(), 59 | } 60 | }; 61 | } 62 | 63 | class Followers with Jsonable { 64 | String href; 65 | int total; 66 | 67 | Followers({this.href, this.total}); 68 | 69 | Followers.fromJson(Map json) { 70 | href = json['href']; 71 | total = json['total']; 72 | } 73 | 74 | @override 75 | Map toJson() => { 76 | 'href': href, 77 | 'total': total, 78 | }; 79 | } 80 | 81 | class PlaylistTrack with Jsonable { 82 | String addedAt; 83 | Artists addedBy; 84 | bool isLocal; 85 | TrackFull track; 86 | 87 | PlaylistTrack({this.addedAt, this.addedBy, this.isLocal, this.track}); 88 | 89 | static PlaylistTrack jsonConverter(Map json) => 90 | json == null ? null : PlaylistTrack.fromJson(json); 91 | 92 | PlaylistTrack.fromJson(Map json) { 93 | addedAt = json['added_at']; 94 | addedBy = 95 | json['added_by'] != null ? Artists.fromJson(json['added_by'] ?? {}) : null; 96 | isLocal = json['is_local']; 97 | track = json['track'] != null ? TrackFull.fromJson(json['track'] ?? {}) : null; 98 | } 99 | 100 | @override 101 | Map toJson() => { 102 | 'added_at': addedAt, 103 | if (addedBy != null) ...{ 104 | 'added_by': addedBy.toJson(), 105 | }, 106 | 'is_local': isLocal, 107 | if (track != null) ...{ 108 | 'track': track.toJson(), 109 | } 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /lib/json/playlist_simplified.dart: -------------------------------------------------------------------------------- 1 | import 'package:Spogit/json/artist.dart'; 2 | import 'package:Spogit/json/image.dart'; 3 | import 'package:Spogit/json/json.dart'; 4 | import 'package:Spogit/json/paging.dart'; 5 | import 'package:Spogit/json/playlist_full.dart'; 6 | import 'package:Spogit/json/sub/external_url.dart'; 7 | import 'package:Spogit/utility.dart'; 8 | 9 | /// [T] is the type of the [tracks] object. In most cases, this should be a 10 | /// [Paging], however in some instances such as listing playlists, 11 | /// this should be a [TracksObject]. 12 | class PlaylistSimplified with Jsonable { 13 | bool collaborative; 14 | ExternalUrls externalUrls; 15 | String href; 16 | String id; 17 | List images; 18 | String name; 19 | Artists owner; 20 | bool public; 21 | String snapshotId; 22 | T tracks; 23 | String type; 24 | String uri; 25 | 26 | PlaylistSimplified( 27 | {this.collaborative, 28 | this.externalUrls, 29 | this.href, 30 | this.id, 31 | this.images, 32 | this.name, 33 | this.owner, 34 | this.public, 35 | this.snapshotId, 36 | this.tracks, 37 | this.type, 38 | this.uri}); 39 | 40 | static PlaylistSimplified jsonConverter(Map json) => 41 | PlaylistSimplified.fromJson(json); 42 | 43 | PlaylistSimplified.fromJson(Map json) { 44 | collaborative = json['collaborative']; 45 | externalUrls = json['external_urls'] != null 46 | ? ExternalUrls.fromJson(json['external_urls'] ?? {}) 47 | : null; 48 | href = json['href']; 49 | id = json['id']; 50 | if (json['images'] != null) { 51 | images = []; 52 | json['images'].forEach((v) { 53 | images.add(Images.fromJson(v)); 54 | }); 55 | } 56 | name = json['name']; 57 | owner = json['owner'] != null ? Artists.fromJson(json['owner'] ?? {}) : null; 58 | public = json['public']; 59 | snapshotId = json['snapshot_id']; 60 | 61 | if (isSubtype()) { 62 | tracks = (json['tracks'] != null 63 | ? Paging.fromJson( 64 | json['tracks'] ?? {}, PlaylistTrack.jsonConverter) 65 | : null) as T; 66 | } else if (T == TracksObject) { 67 | tracks = TracksObject.fromJson(json['tracks'] ?? {}) as T; 68 | } 69 | 70 | type = json['type']; 71 | uri = json['uri']; 72 | } 73 | 74 | @override 75 | Map toJson() => { 76 | 'collaborative': collaborative, 77 | if (externalUrls != null) ...{ 78 | 'external_urls': externalUrls.toJson(), 79 | }, 80 | 'href': href, 81 | 'id': id, 82 | if (images != null) ...{ 83 | 'images': images.map((v) => v.toJson()).toList(), 84 | }, 85 | 'name': name, 86 | if (owner != null) ...{ 87 | 'owner': owner.toJson(), 88 | }, 89 | 'public': public, 90 | 'snapshot_id': snapshotId, 91 | if (tracks != null) ...{ 92 | 'tracks': tracks.toJson(), 93 | }, 94 | 'type': type, 95 | 'uri': uri, 96 | }; 97 | } 98 | 99 | class TracksObject with Jsonable { 100 | String href; 101 | int total; 102 | 103 | TracksObject(this.href, this.total); 104 | 105 | TracksObject.fromJson(Map json) 106 | : href = json['href'], 107 | total = json['total']; 108 | 109 | @override 110 | Map toJson() => { 111 | 'href': href, 112 | 'total': total, 113 | }; 114 | } 115 | -------------------------------------------------------------------------------- /lib/json/sub/external_ids.dart: -------------------------------------------------------------------------------- 1 | class ExternalType { 2 | static const ISRC = 3 | ExternalType('isrc'); // International Standard Recording Code 4 | static const EAN = ExternalType('ean'); // International Article Number 5 | static const UPC = ExternalType('upc'); // Universal Product Code 6 | 7 | final String name; 8 | 9 | const ExternalType(this.name); 10 | } 11 | 12 | class ExternalIds { 13 | final String data; 14 | final ExternalType type; 15 | 16 | ExternalIds(this.type, {this.data}); 17 | 18 | ExternalIds.fromJson(this.type, Map json) 19 | : data = json[type.name]; 20 | 21 | Map toJson() => {type.name: data}; 22 | } 23 | -------------------------------------------------------------------------------- /lib/json/sub/external_url.dart: -------------------------------------------------------------------------------- 1 | class ExternalUrls { 2 | final String spotify; 3 | 4 | ExternalUrls({this.spotify}); 5 | 6 | ExternalUrls.fromJson(Map json) : spotify = json['spotify']; 7 | 8 | Map toJson() => {'spotify': spotify}; 9 | } 10 | -------------------------------------------------------------------------------- /lib/json/track_full.dart: -------------------------------------------------------------------------------- 1 | import 'package:Spogit/json/album_simplified.dart'; 2 | import 'package:Spogit/json/artist.dart'; 3 | import 'package:Spogit/json/json.dart'; 4 | import 'package:Spogit/json/sub/external_ids.dart'; 5 | import 'package:Spogit/json/sub/external_url.dart'; 6 | 7 | class TrackFull with Jsonable { 8 | AlbumSimplified album; 9 | List artists; 10 | List availableMarkets; 11 | int discNumber; 12 | int durationMs; 13 | bool explicit; 14 | ExternalIds externalIds; 15 | ExternalUrls externalUrls; 16 | String href; 17 | String id; 18 | bool isLocal; 19 | String name; 20 | int popularity; 21 | String previewUrl; 22 | int trackNumber; 23 | String type; 24 | String uri; 25 | 26 | TrackFull( 27 | {this.album, 28 | this.artists, 29 | this.availableMarkets, 30 | this.discNumber, 31 | this.durationMs, 32 | this.explicit, 33 | this.externalIds, 34 | this.externalUrls, 35 | this.href, 36 | this.id, 37 | this.isLocal, 38 | this.name, 39 | this.popularity, 40 | this.previewUrl, 41 | this.trackNumber, 42 | this.type, 43 | this.uri}); 44 | 45 | static TrackFull jsonConverter(Map json) => json == null ? null : TrackFull.fromJson(json); 46 | 47 | TrackFull.fromJson(Map json) { 48 | album = json['album'] != null ? AlbumSimplified.fromJson(json['album'] ?? {}) : null; 49 | if (json['artists'] != null) { 50 | artists = []; 51 | json['artists'].forEach((v) { 52 | artists.add(Artists.fromJson(v)); 53 | }); 54 | } 55 | availableMarkets = json['available_markets']?.cast(); 56 | discNumber = json['disc_number']; 57 | durationMs = json['duration_ms']; 58 | explicit = json['explicit']; 59 | externalIds = json['external_ids'] != null 60 | ? ExternalIds.fromJson(ExternalType.ISRC, json['external_ids']) 61 | : null; 62 | externalUrls = json['external_urls'] != null 63 | ? ExternalUrls.fromJson(json['external_urls']) 64 | : null; 65 | href = json['href']; 66 | id = json['id']; 67 | isLocal = json['is_local']; 68 | name = json['name']; 69 | popularity = json['popularity']; 70 | previewUrl = json['preview_url']; 71 | trackNumber = json['track_number']; 72 | type = json['type']; 73 | uri = json['uri']; 74 | } 75 | 76 | @override 77 | Map toJson() { 78 | final Map data = new Map(); 79 | if (this.album != null) { 80 | data['album'] = this.album.toJson(); 81 | } 82 | if (this.artists != null) { 83 | data['artists'] = this.artists.map((v) => v.toJson()).toList(); 84 | } 85 | data['available_markets'] = this.availableMarkets; 86 | data['disc_number'] = this.discNumber; 87 | data['duration_ms'] = this.durationMs; 88 | data['explicit'] = this.explicit; 89 | if (this.externalIds != null) { 90 | data['external_ids'] = this.externalIds.toJson(); 91 | } 92 | if (this.externalUrls != null) { 93 | data['external_urls'] = this.externalUrls.toJson(); 94 | } 95 | data['href'] = this.href; 96 | data['id'] = this.id; 97 | data['is_local'] = this.isLocal; 98 | data['name'] = this.name; 99 | data['popularity'] = this.popularity; 100 | data['preview_url'] = this.previewUrl; 101 | data['track_number'] = this.trackNumber; 102 | data['type'] = this.type; 103 | data['uri'] = this.uri; 104 | return data; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /lib/json/track_simplified.dart: -------------------------------------------------------------------------------- 1 | import 'package:Spogit/json/artist.dart'; 2 | import 'package:Spogit/json/json.dart'; 3 | import 'package:Spogit/json/sub/external_url.dart'; 4 | 5 | class TrackSimplified with Jsonable { 6 | List artists; 7 | List availableMarkets; 8 | int discNumber; 9 | int durationMs; 10 | bool explicit; 11 | ExternalUrls externalUrls; 12 | String href; 13 | String id; 14 | String name; 15 | String previewUrl; 16 | int trackNumber; 17 | String type; 18 | String uri; 19 | 20 | TrackSimplified( 21 | {this.artists, 22 | this.availableMarkets, 23 | this.discNumber, 24 | this.durationMs, 25 | this.explicit, 26 | this.externalUrls, 27 | this.href, 28 | this.id, 29 | this.name, 30 | this.previewUrl, 31 | this.trackNumber, 32 | this.type, 33 | this.uri}); 34 | 35 | static TrackSimplified jsonConverter(Map json) => json == null ? null : TrackSimplified.fromJson(json); 36 | 37 | TrackSimplified.fromJson(Map json) { 38 | if (json['artists'] != null) { 39 | artists = new List(); 40 | json['artists'].forEach((v) { 41 | artists.add(new Artists.fromJson(v)); 42 | }); 43 | } 44 | availableMarkets = json['available_markets'].cast(); 45 | discNumber = json['disc_number']; 46 | durationMs = json['duration_ms']; 47 | explicit = json['explicit']; 48 | externalUrls = json['external_urls'] != null 49 | ? new ExternalUrls.fromJson(json['external_urls']) 50 | : null; 51 | href = json['href']; 52 | id = json['id']; 53 | name = json['name']; 54 | previewUrl = json['preview_url']; 55 | trackNumber = json['track_number']; 56 | type = json['type']; 57 | uri = json['uri']; 58 | } 59 | 60 | @override 61 | Map toJson() { 62 | final Map data = new Map(); 63 | if (this.artists != null) { 64 | data['artists'] = this.artists.map((v) => v.toJson()).toList(); 65 | } 66 | data['available_markets'] = this.availableMarkets; 67 | data['disc_number'] = this.discNumber; 68 | data['duration_ms'] = this.durationMs; 69 | data['explicit'] = this.explicit; 70 | if (this.externalUrls != null) { 71 | data['external_urls'] = this.externalUrls.toJson(); 72 | } 73 | data['href'] = this.href; 74 | data['id'] = this.id; 75 | data['name'] = this.name; 76 | data['preview_url'] = this.previewUrl; 77 | data['track_number'] = this.trackNumber; 78 | data['type'] = this.type; 79 | data['uri'] = this.uri; 80 | return data; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lib/local_manager.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:Spogit/Spogit.dart'; 5 | import 'package:Spogit/cache/cache_manager.dart'; 6 | import 'package:Spogit/cache/cover_resource.dart'; 7 | import 'package:Spogit/driver/driver_api.dart'; 8 | import 'package:Spogit/driver/playlist_manager.dart'; 9 | import 'package:Spogit/fs/playlist.dart'; 10 | import 'package:Spogit/utility.dart'; 11 | import 'package:logging/logging.dart'; 12 | 13 | const bool APPLY_COVERS = false; 14 | 15 | class LocalManager { 16 | final log = Logger('LocalManager'); 17 | 18 | final Spogit spogit; 19 | final DriverAPI driverAPI; 20 | final CacheManager cacheManager; 21 | final Directory root; 22 | final List linkedPlaylists = []; 23 | 24 | LocalManager(this.spogit, this.driverAPI, this.cacheManager, this.root); 25 | 26 | /// Should be invoked once at the beginning for initialization. 27 | Future> getExistingRoots( 28 | BaseRevision baseRevision) async { 29 | linkedPlaylists.clear(); 30 | for (var dir in root.listSync()) { 31 | if ([dir, 'meta.json'].file.existsSync()) { 32 | print('Found a directory with a meta.json in it: ${dir.path}'); 33 | 34 | if ([dir, 'local'].file.existsSync()) { 35 | log.fine('Directory has already been locally synced'); 36 | var linked = LinkedPlaylist.preLinked(spogit, this, dir); 37 | 38 | var rootLocal = linked.root.rootLocal; 39 | 40 | log.fine( 41 | 'rootLocalRev = ${rootLocal.revision} baseRev = ${baseRevision.revision}'); 42 | 43 | // TODO: Check if the contents have changed instead of just revision # 44 | if (rootLocal.revision != baseRevision.revision) { 45 | log.fine('Revisions do not match, pulling Spotify changes to local'); 46 | await linked.refreshFromRemote(); 47 | } 48 | 49 | linkedPlaylists.add(linked); 50 | } else { 51 | log.fine( 52 | 'Directory has not been locally synced, creating and pushing to remote now'); 53 | var local = LinkedPlaylist.fromLocal(spogit, this, dir); 54 | await local.initLocal(true); 55 | linkedPlaylists.add(local); 56 | } 57 | } 58 | } 59 | 60 | return linkedPlaylists; 61 | } 62 | 63 | /// Gets the LinkedPlaylist from any [id] it contains. 64 | LinkedPlaylist getFromAnyId(String name) => 65 | linkedPlaylists.firstWhere((linked) => linked.root.root.uri.realName == name, orElse: () => null); 66 | 67 | void addPlaylist(LinkedPlaylist linkedPlaylist) => 68 | linkedPlaylists.add(linkedPlaylist); 69 | 70 | LinkedPlaylist getPlaylist(Directory directory) => 71 | linkedPlaylists.firstWhere((playlist) => playlist.root.root == directory, 72 | orElse: () => null); 73 | 74 | /// If the given [url] is different than what is in the cache, the cache will 75 | /// be updated and this new [url] will be returned. If the [url] matches the 76 | /// cached version, null is returned. 77 | String getCoverUrl(String id, String url) { 78 | if (url == null) { 79 | return null; 80 | } 81 | 82 | var generated = cacheManager 83 | .getOrSync( 84 | id, () => PlaylistCoverResource(id, url), 85 | forceUpdate: (resource) => resource.data != url ?? true) 86 | .generated; 87 | return generated ? url : null; 88 | } 89 | } 90 | 91 | class LinkedPlaylist { 92 | final log = Logger('LinkedPlaylist'); 93 | 94 | final Spogit spogit; 95 | final LocalManager localManager; 96 | final CacheManager cacheManager; 97 | final DriverAPI driverAPI; 98 | final SpogitRoot root; 99 | 100 | PlaylistManager get playlists => driverAPI.playlistManager; 101 | 102 | /// A flat list of [RevisionElement]s 103 | List elements; 104 | 105 | /// Creates a [LinkedPlaylist] from 106 | LinkedPlaylist.preLinked(this.spogit, this.localManager, Directory directory) 107 | : cacheManager = spogit.cacheManager, 108 | driverAPI = spogit.driverAPI, 109 | root = SpogitRoot(spogit, directory) { 110 | elements = []; 111 | } 112 | 113 | /// Creates a [LinkedPlaylist] from an already populated purley local 114 | /// directory in ~/Spogit. Upon creation, this will update the Spotify API 115 | /// if no `local` files are found. 116 | LinkedPlaylist.fromLocal(this.spogit, this.localManager, Directory directory) 117 | : cacheManager = spogit.cacheManager, 118 | driverAPI = spogit.driverAPI, 119 | root = SpogitRoot(spogit, directory, creating: true) { 120 | elements = []; 121 | } 122 | 123 | /// Creates a [LinkedPlaylist] from a given [BaseRevision] and list of 124 | /// top-level Spotify folders/playlists as [elementIds]. This means that it 125 | /// should not be fed a child playlist or folder. 126 | LinkedPlaylist.fromRemote(this.spogit, this.localManager, Directory spogitPath, String name, 127 | BaseRevision baseRevision, List elementIds) 128 | : cacheManager = spogit.cacheManager, 129 | driverAPI = spogit.driverAPI, 130 | root = SpogitRoot(spogit, [spogitPath, name].directoryRaw, 131 | creating: true, tracking: elementIds) { 132 | root.rootLocal.revision = baseRevision.revision; 133 | 134 | updateElements(baseRevision, elementIds); 135 | 136 | cacheManager.clearCacheFor(elements.map((element) => element.id).toList()); 137 | } 138 | 139 | void updateElements(BaseRevision baseRevision, List elementIds) { 140 | elements = []; 141 | 142 | elementIds.parseAll(); 143 | for (var element in baseRevision.elements 144 | .where((element) => elementIds.contains(element.id))) { 145 | if (element.type == ElementType.FolderStart) { 146 | // Gets the elements starting from the start going over all children, and plus the end folder 147 | elements.addAll(baseRevision.elements 148 | .sublist(element.index, element.index + element.children + 1)); 149 | } else { 150 | elements.add(element); 151 | } 152 | } 153 | 154 | var playlistCount = elements.where((element) => element.type == ElementType.Playlist).length; 155 | 156 | log.info('Processing $playlistCount playlists...'); 157 | } 158 | 159 | /// Returns a list of root watching IDs 160 | Future> initLocal([bool forceCreate = true]) async { 161 | Future traverse( 162 | Mappable mappable, List parents) async { 163 | var to = parents.safeLast?.spotifyId; 164 | 165 | if (mappable.spotifyId != null && !forceCreate) { 166 | return null; 167 | } 168 | 169 | if (mappable is SpotifyPlaylist) { 170 | var id = (await playlists.createPlaylist( 171 | mappable.name, mappable.description))['id']; 172 | 173 | if (APPLY_COVERS) { 174 | await playlists.uploadCover(mappable.coverImage, id); 175 | } 176 | 177 | await playlists.movePlaylist(id, toGroup: to); 178 | 179 | await playlists.addTracks( 180 | id, mappable.songs.map((song) => song.id).toList()); 181 | 182 | mappable.spotifyId = id; 183 | return id; 184 | } else if (mappable is SpotifyFolder) { 185 | var id = (await playlists.createFolder(mappable.name, 186 | toGroup: to))['id'] as String; 187 | mappable.spotifyId = id; 188 | 189 | for (var child in mappable.children) { 190 | await traverse(child, [...parents, mappable]); 191 | } 192 | 193 | return id; 194 | } 195 | 196 | return null; 197 | } 198 | 199 | var pl = root.children; 200 | var res = []; 201 | 202 | for (var child in pl) { 203 | res.add(await traverse(child, [])); 204 | } 205 | 206 | root.rootLocal.tracking = res; 207 | 208 | return res; 209 | } 210 | 211 | /// Initializes the [root] with the set [elements] via [updateElements]. 212 | Future initElement() async { 213 | await parseElementsToContainer(root, elements); 214 | 215 | await root.save(); 216 | } 217 | 218 | Future refreshFromRemote() async { 219 | root.root.deleteSync(recursive: true); 220 | await initElement(); 221 | await root.save(); 222 | } 223 | 224 | Future pullRemote(BaseRevision baseRevision, List ids) async { 225 | print(root.children.map((map) => map.spotifyId).join(', ')); 226 | var mappables = 227 | root.children.where((mappable) => ids.contains(mappable.spotifyId)); 228 | 229 | if (mappables.isEmpty) { 230 | return; 231 | } 232 | 233 | // So currently the local elements (flat) have not been updated and are out of date. 234 | // baseRevision is updated, with a flat element list, and only "ids" should be updated 235 | // So we need to take local root and replace the overlapping stuff 236 | 237 | // print('Outdated elements: $elements'); 238 | 239 | elements.clear(); 240 | elements.addAll(baseRevision.elements); 241 | 242 | var idMap = elements 243 | .where((element) => 244 | element.type != ElementType.FolderEnd && ids.contains(element.id)) 245 | .toList() 246 | .asMap() 247 | .map((i, element) => MapEntry(element.id, element)); 248 | 249 | for (var id in ids) { 250 | var element = idMap[id]; 251 | if (element.type == ElementType.Playlist) { 252 | var playlistDetails = 253 | await driverAPI.playlistManager.getPlaylistInfo(id); 254 | 255 | root.replacePlaylist(id) 256 | ..name = element.name 257 | ..description = playlistDetails.description 258 | ..imageUrl = localManager.getCoverUrl( 259 | id, playlistDetails.images?.safeFirst?.url) 260 | ..songs = List.from(playlistDetails?.tracks?.items 261 | ?.map((track) { 262 | // TODO: Investigate 263 | var song = SpotifySong.fromJson(spogit, track); 264 | if (song.id == null) { 265 | print('id null from json'); 266 | print('from id $id | $element'); 267 | print(playlistDetails.toJson()); 268 | } 269 | return song; 270 | }) ?? 271 | const []); 272 | } else if (element.type == ElementType.FolderStart) { 273 | var replaced = root.replaceFolder(id); 274 | var start = element.index; 275 | var end = element.index + element.moveCount; 276 | if (++start >= --end) { 277 | // This should never happen 278 | log.fine('start >= end so not copying anything over'); 279 | continue; 280 | } 281 | 282 | await parseElementsToContainer(replaced, elements.sublist(start, end)); 283 | } 284 | } 285 | 286 | await root.save(); 287 | } 288 | 289 | Future parseElementsToContainer( 290 | SpotifyContainer container, List elements) async { 291 | // Was just `current = root` before, not sure if changing this will work? 292 | var current = container; 293 | for (var element in elements) { 294 | var id = element.id; 295 | switch (element.type) { 296 | case ElementType.Playlist: 297 | var playlistDetails = 298 | await driverAPI.playlistManager.getPlaylistInfo(id); 299 | 300 | current.addPlaylist(element.name) 301 | ..spotifyId = id 302 | ..name = element.name 303 | ..description = playlistDetails.description 304 | ..imageUrl = localManager.getCoverUrl( 305 | id, playlistDetails.images?.safeFirst?.url) 306 | ..songs = List.from(playlistDetails?.tracks?.items 307 | ?.map((track) => SpotifySong.fromJson(spogit, track)) ?? 308 | const []); 309 | break; 310 | case ElementType.FolderStart: 311 | current = (current.addFolder(element.name)..spotifyId = id); 312 | break; 313 | case ElementType.FolderEnd: 314 | current = current.parent; 315 | break; 316 | } 317 | } 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /lib/markdown/md_generator.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | abstract class MarkdownGenerator { 4 | String generate(); 5 | } 6 | 7 | class Template { 8 | final List _pieces; 9 | final Template Function() _overflowAppend; 10 | String _resulting = ''; 11 | 12 | Template._(List pieces, [this._overflowAppend]) 13 | : _pieces = [...pieces]; 14 | 15 | /// Constructs a template from the given [template] string, using 16 | /// [placeholder] as a placeholder for where data will be inserted. 17 | /// Is [staticOverflowAppend] is ser, the given [Template] will be appended 18 | /// when data is inserted and there is no space for it. Similarly, 19 | /// [overflowAppend] is invoked to create a [Template] in the same scenario. 20 | /// If [duplicateAppend] is true and [overflowAppend] is unset, the Template 21 | /// will duplicate its initial self and add it to the end, useful for things 22 | /// like tables or lists. 23 | factory Template.construct(String template, 24 | {Template staticOverflowAppend, 25 | Template Function() overflowAppend, 26 | bool duplicateAppend = false, 27 | String placeholder = '%'}) { 28 | var placeholding = []; 29 | 30 | var iterator = template.split(placeholder).iterator; 31 | if (iterator.moveNext()) { 32 | placeholding.add(Placeholder(iterator.current)); 33 | 34 | while (iterator.moveNext()) { 35 | placeholding.add(const Placeholder()); 36 | placeholding.add(Placeholder(iterator.current)); 37 | } 38 | } 39 | 40 | if (duplicateAppend) { 41 | overflowAppend ??= () => Template._(placeholding); 42 | } else { 43 | overflowAppend ??= () => staticOverflowAppend; 44 | } 45 | 46 | return Template._(placeholding, overflowAppend); 47 | } 48 | 49 | void insertPlaceholder(String data) { 50 | void handle(String str) => _resulting += str; 51 | 52 | var filledAny = false; 53 | int clearPieces() { 54 | for (var i = 0; i < _pieces.length; i++) { 55 | var curr = _pieces[i]; 56 | if (curr.type == PlaceholderType.Empty) { 57 | filledAny = true; 58 | handle(data); 59 | return i + 1; 60 | } else { 61 | handle(curr.data); 62 | } 63 | } 64 | return _pieces.length; 65 | } 66 | 67 | _pieces.removeRange(0, clearPieces()); 68 | 69 | if (!filledAny) { 70 | if (_pieces.isEmpty) { 71 | if (_overflowAppend == null) { 72 | return; 73 | } 74 | 75 | _pieces.addAll([..._overflowAppend()._pieces]); 76 | } 77 | 78 | _pieces.removeRange(0, clearPieces()); 79 | } 80 | } 81 | 82 | void appendString(String data) => _pieces.add(Placeholder(data)); 83 | 84 | String build([String remainingPlaceholders = '']) => 85 | '${_resulting}${_pieces.map((place) => place.type == PlaceholderType.Empty ? remainingPlaceholders : place.data).join()}'; 86 | } 87 | 88 | class Placeholder { 89 | final PlaceholderType type; 90 | 91 | /// Constant data if [type] is [PlaceholderType.Constant]. 92 | final String data; 93 | 94 | const Placeholder([this.data]) 95 | : type = data == null ? PlaceholderType.Empty : PlaceholderType.Constant; 96 | } 97 | 98 | enum PlaceholderType { Constant, Empty } 99 | -------------------------------------------------------------------------------- /lib/markdown/readme.dart: -------------------------------------------------------------------------------- 1 | 2 | final descriptionRegex = RegExp(r'\[start\-desc\]: #\s+(.*?)\s+\[end\-desc\]: #', dotAll: true); 3 | final linkRegex = RegExp(r'\[([^\\]*?)\]\(.*?spotify:track:([a-zA-Z0-9]{22})\)'); 4 | 5 | class Readme { 6 | int titleLevel; 7 | String title; 8 | String description; 9 | String descriptionPlaceholder; 10 | String content; 11 | 12 | Readme.createTitled({this.titleLevel = 2, this.title, this.description, this.descriptionPlaceholder = 'Replace this line with a description persistent with the repository.', this.content}); 13 | 14 | factory Readme.parse(String content) { 15 | content = content.trim(); 16 | 17 | var titleLevel = 2; 18 | String title; 19 | var contentStart = 0; 20 | 21 | if (content.startsWith('#')) { 22 | var space = content.indexOf(' '); 23 | var hashes = content.substring(0, space); 24 | contentStart = content.indexOf('\n'); 25 | titleLevel = hashes.length; 26 | title = content.substring(space, contentStart).trim(); 27 | } 28 | 29 | var first = descriptionRegex.firstMatch(content); 30 | 31 | var description = first?.group(1); 32 | var remaining = content.substring(first?.end ?? 0); 33 | 34 | return Readme.createTitled(titleLevel: titleLevel, title: title, description: description, content: remaining); 35 | } 36 | 37 | String create() => ''' 38 | ${'#' * titleLevel} $title 39 | [start-desc]: # 40 | 41 | ${description ?? '[//]: # ($descriptionPlaceholder)'} 42 | 43 | [end-desc]: # 44 | 45 | $content 46 | '''; 47 | } 48 | -------------------------------------------------------------------------------- /lib/markdown/table_generator.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:Spogit/markdown/md_generator.dart'; 4 | import 'package:Spogit/utility.dart'; 5 | 6 | class TableGenerator extends MarkdownGenerator { 7 | final List data; 8 | final int columns; 9 | 10 | String get _rowString => ('%|' * columns) - 1; 11 | 12 | String get _divider => (':--:|' * columns) - 1; 13 | 14 | TableGenerator(this.data, {int columns = 3, bool formFitting = true}) 15 | : columns = formFitting ? min(data.length, columns) : columns; 16 | 17 | @override 18 | String generate() { 19 | final rowTemplate = 20 | Template.construct('${_rowString}\n', duplicateAppend: true); 21 | 22 | data.take(columns).forEach(rowTemplate.insertPlaceholder); 23 | rowTemplate.appendString('$_divider\n'); 24 | data.skip(columns).forEach(rowTemplate.insertPlaceholder); 25 | 26 | return rowTemplate.build(''); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/setup.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:Spogit/utility.dart'; 4 | import 'package:logging/logging.dart'; 5 | 6 | Future main(List args) async { 7 | await Setup().setup(); 8 | } 9 | 10 | class Setup { 11 | final log = Logger('Setup'); 12 | 13 | final hooks = const { 14 | 'post-checkout': r''' 15 | #!/bin/bash 16 | 17 | DOT_SPOGIT="$(dirname "$(pwd)")/.spogit" 18 | 19 | if [ -f "$DOT_SPOGIT" ]; then 20 | echo "Updating Spogit" 21 | printf "GET /post-checkout?prev=$1&new=$2&from-branch=$3&pwd=$(pwd) HTTP/1.0\r\n\r\n" > /dev/tcp/localhost/9082 22 | fi 23 | ''', 24 | }; 25 | 26 | /// Should only be invoked once, such as when logging in. 27 | /// Adds relevant hooks to the default git repo template 28 | Future setup() async { 29 | log.info('Creating and setting hooks...'); 30 | try { 31 | var templateDir = await getTemplateDirectory(); 32 | var hooksDir = [templateDir, 'hooks'].directoryRaw; 33 | 34 | await hooksDir.create(recursive: true); 35 | 36 | for (var name in hooks.keys) { 37 | var outFile = [hooksDir, name].fileRaw; 38 | if (!(await outFile.exists())) { 39 | await outFile.create(); 40 | hooks[name] >> outFile; 41 | log.info('Added $name hook'); 42 | } else { 43 | log.info('"${outFile.path}" already exists, not adding hook.'); 44 | } 45 | } 46 | 47 | log.info('Created hooks'); 48 | } on FileSystemException catch (e, s) { 49 | log.severe('Unable to create hook', e, s); 50 | print(e); 51 | } 52 | } 53 | 54 | Future getTemplateDirectory() async { 55 | var info = (await gitCommand('--info-path')).directory.parent; 56 | var man = (await gitCommand('--man-path')).directory.parent; 57 | var html = (await gitCommand('--html-path')).directory.parent.parent; 58 | 59 | var share = getSimilarity([info, man, html]) ?? info; 60 | return [share, 'git-core', 'templates'].directoryRaw; 61 | } 62 | 63 | T getSimilarity(List data, [int minEquals = 2]) { 64 | var equalsData = >[]; 65 | 66 | for (var i = 0; i < data.length; i++) { 67 | var outer = data[i]; 68 | var thisEquals = []; 69 | for (var j = 0; j < data.length; j++) { 70 | if (j != i && data[j] == outer) { 71 | thisEquals.add(data[j]); 72 | } 73 | } 74 | 75 | if (thisEquals.length >= minEquals) { 76 | equalsData.add(thisEquals); 77 | } 78 | } 79 | 80 | if (equalsData.isEmpty) { 81 | return null; 82 | } 83 | 84 | equalsData.sort((list1, list2) => list2.length.compareTo(list1.length)); 85 | return equalsData.safeFirst?.first; 86 | } 87 | 88 | Future gitCommand(String command) async => 89 | (await Process.run('git', [command])).stdout; 90 | } 91 | -------------------------------------------------------------------------------- /lib/url_browser.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | void browseUrl(String url) { 4 | var result = { 5 | Platform.isMacOS: ['open', [url]], 6 | Platform.isLinux: ['xdg-open', [url]], 7 | Platform.isWindows: ['cmd', ['/c', 'start', url]], 8 | }[true]; 9 | 10 | Process.start(result[0], result[1]); 11 | } 12 | -------------------------------------------------------------------------------- /lib/utility.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | import 'dart:math'; 5 | import 'dart:typed_data'; 6 | 7 | import 'package:collection/collection.dart'; 8 | import 'package:http/http.dart' as http; 9 | 10 | final env = Platform.environment; 11 | 12 | final listEquals = ListEquality().equals; 13 | 14 | final mapEquals = MapEquality().equals; 15 | 16 | final random = Random(); 17 | 18 | String get userHome => { 19 | Platform.isMacOS: env['HOME'], 20 | Platform.isLinux: env['HOME'], 21 | Platform.isWindows: env['UserProfile'], 22 | }[true]; 23 | 24 | Directory get userHomeDir => Directory(userHome); 25 | 26 | String get separator => Platform.pathSeparator; 27 | 28 | int get now => DateTime.now().millisecondsSinceEpoch; 29 | 30 | void syncPeriodic(Duration duration, Function callback) { 31 | callback(); 32 | Timer(duration, () async => await syncPeriodic(duration, callback)); 33 | } 34 | 35 | Future awaitSleep(Duration duration) { 36 | final completer = Completer(); 37 | Timer(duration, () => completer.complete()); 38 | return completer.future; 39 | } 40 | 41 | String randomHex(int length) { 42 | var res = ''; 43 | for (var i = 0; i < length / 2; i++) { 44 | res += random.nextInt(0xFF).toRadixString(16); 45 | } 46 | return res; 47 | } 48 | 49 | Uint8List fromMatcher(List data) => Uint8List.fromList(List.of( 50 | data.map((value) => value is String ? value.codeUnitAt(0) : value)) 51 | .toList()); 52 | 53 | void printConsole(Object obj) => print(obj); 54 | 55 | V access(Map map, K key, [V def]) { 56 | if (map == null) { 57 | return def; 58 | } 59 | 60 | var val = map[key]; 61 | return val ?? def; 62 | } 63 | 64 | File dynamicFile(dynamic file) { 65 | if (file is File) { 66 | return file; 67 | } else if (file is String) { 68 | return file.file; 69 | } else if (file is List) { 70 | return file.file; 71 | } else { 72 | throw 'Invalid type for file: ${file.runtimeType}'; 73 | } 74 | } 75 | 76 | String escapeSlash(String input) => input.replaceAll('/', ' ∕ '); 77 | 78 | String unescapeSlash(String input) => input.replaceAll(' ∕ ', '/'); 79 | 80 | /// Checks whether [T1] is a (not necessarily proper) subtype of [T2]. 81 | ///

Author: [Irn](https://stackoverflow.com/a/50198267/3929546) 82 | bool isSubtype() => [] is List; 83 | 84 | // Json utils 85 | 86 | Map jsonify(Map map) => 87 | Map.from(map); 88 | 89 | Map tryJsonDecode(String json, 90 | [dynamic def = const {}]) { 91 | try { 92 | return jsonDecode(json) ?? def; 93 | } on FormatException catch (e) { 94 | return def; 95 | } 96 | } 97 | 98 | // Extensions 99 | 100 | extension StringUtils on String { 101 | static final QUOTES_REGEX = RegExp('[^\\s"\']+|"([^"]*)"|\'([^\']*)\''); 102 | 103 | int parseInt() => int.tryParse(this); 104 | 105 | double parseDouble() => double.parse(this); 106 | 107 | String get separatorFix => 108 | (startsWith('~') ? '$userHome${substring(1, length)}' : this) 109 | .replaceAll('/', separator); 110 | 111 | File get file => File(separatorFix); 112 | 113 | Directory get directory => Directory(separatorFix); 114 | 115 | Uri get uri => Uri.tryParse(this); 116 | 117 | List splitMulti(List strings) { 118 | var list = [this]; 119 | for (var value in strings) { 120 | list = list.expand((inner) => inner.split(value)).toList(); 121 | } 122 | return list; 123 | } 124 | 125 | List splitQuotes() => QUOTES_REGEX 126 | .allMatches(this) 127 | .map((match) => match.group(1) ?? match.group(0)) 128 | .toList(); 129 | 130 | String safeSubstring(int startIndex, [int endIndex]) { 131 | if (startIndex >= length || (endIndex != null && startIndex >= startIndex + length)) { 132 | return ''; 133 | } 134 | return substring(startIndex, endIndex); 135 | } 136 | 137 | String blockTrim() { 138 | var lines = split('\n'); 139 | var mi = lines.map((line) => line.leftSpace).reduce(min); 140 | return lines.map((line) => line.substring(mi)).join('\n'); 141 | } 142 | 143 | int get leftSpace { 144 | for (var i = 0; i < length; i++) { 145 | if (this[i] != ' ') { 146 | return i; 147 | } 148 | } 149 | return length; 150 | } 151 | 152 | /// Pipes the string to the [file] on the right. This [file] may be either a 153 | /// [String] path, or a [File]. If the file is not created, it will create it. 154 | /// Returns the [File] being written to. 155 | File operator >>(dynamic file) { 156 | var realFile = dynamicFile(file); 157 | realFile.writeAsStringSync(this, mode: FileMode.writeOnly); 158 | return realFile; 159 | } 160 | 161 | /// Removes [amount] characters from the end of the string. 162 | String operator -(int amount) => substring(0, max(0, length - amount)); 163 | } 164 | 165 | extension NumUtil on int { 166 | String fixedLeftPad(int totalLength, [String padding = '0']) { 167 | var str = toString(); 168 | return '${padding * (totalLength - str.length)}$str'; 169 | } 170 | 171 | int add(int num) => this + num; 172 | 173 | int sub(int num) => this - num; 174 | } 175 | 176 | extension PathUtils on List { 177 | String separatorFix([bool replaceSlashes = false]) { 178 | return map((e) => (e is File || e is Directory ? e.path : escapeSlash(e)) as String) 179 | .where((str) => str.isNotEmpty) 180 | .join(separator); 181 | } 182 | 183 | /// Creates a [File] from the current path. 184 | /// Replaces all slashes with the division symbol. 185 | File get file => File(separatorFix(true)); 186 | 187 | /// Creates a [File] from the current path. 188 | /// DOES NOT replace slashes with the division symbol. 189 | File get fileRaw => File(separatorFix()); 190 | 191 | /// Creates a [Directory] from the current path. 192 | /// Replaces all slashes with the division symbol. 193 | Directory get directory => Directory(separatorFix(true)); 194 | 195 | /// Creates a [Directory] from the current path. 196 | /// DOES NOT replace slashes with the division symbol. 197 | Directory get directoryRaw => Directory(separatorFix()); 198 | } 199 | 200 | extension ASCIIShit on int { 201 | bool get isASCII => (this == 10 || this == 13 || (this >= 32 && this <= 126)); 202 | 203 | bool get isNotASCII => !isASCII; 204 | } 205 | 206 | extension PrintStuff on T { 207 | T print([String leading = '', String trailing = '']) { 208 | printConsole('$leading${this}$trailing'); 209 | return this; 210 | } 211 | } 212 | 213 | extension ResponseUtils on http.Response { 214 | Map get json => tryJsonDecode(body); 215 | } 216 | 217 | extension UriUtils on Uri { 218 | String get realName { 219 | if (pathSegments.length > 1 && pathSegments.last.isEmpty) { 220 | return ([...pathSegments]..removeLast()).last; 221 | } 222 | 223 | return pathSegments.last; 224 | } 225 | } 226 | 227 | // Extensions meant for general safety/ease of use of stuff 228 | 229 | extension SafeUtils on List { 230 | T get safeLast => isNotEmpty ? last : null; 231 | 232 | T get safeFirst => isNotEmpty ? first : null; 233 | 234 | /// If the current list contains all elements as the given [elements]. 235 | bool containsAll(List elements) { 236 | for (var value in elements) { 237 | if (!contains(value)) { 238 | return false; 239 | } 240 | } 241 | 242 | return true; 243 | } 244 | 245 | /// If both the current and given [elements] contains only the same elements. Order 246 | /// is not mandatory. 247 | bool elementsEqual(List elements) => 248 | elements.length == length && 249 | containsAll(elements) && 250 | elements.containsAll(this); 251 | } 252 | 253 | extension DirUtils on Directory { 254 | void tryCreateSync([bool recursive = true]) { 255 | if (existsSync()) { 256 | createSync(recursive: recursive); 257 | } 258 | } 259 | 260 | Future tryCreate([bool recursive = true]) async => 261 | exists().then((exists) async { 262 | if (!exists) { 263 | await create(recursive: recursive); 264 | } 265 | }); 266 | 267 | /// Deletes all children synchronously, preserving the current [Directory]. 268 | void deleteChildrenSync() => 269 | listSync().forEach((entity) => entity.deleteSync()); 270 | 271 | // /// Deletes all children asynchronously, preserving the current [Directory]. 272 | // Future deleteChildren() async => 273 | // (await list().toList()).forEach((entity) async => await entity.delete()); 274 | } 275 | 276 | extension FileUtils on File { 277 | void tryCreateSync([bool recursive = true]) { 278 | if (!existsSync()) { 279 | createSync(recursive: recursive); 280 | } 281 | } 282 | 283 | Future tryCreate([bool recursive = true]) async => 284 | exists().then((exists) async { 285 | if (!exists) { 286 | await create(recursive: recursive); 287 | } 288 | }); 289 | 290 | String tryReadSync({bool create = true, String def = ''}) { 291 | if (existsSync()) { 292 | return readAsStringSync(); 293 | } else if (create) { 294 | createSync(recursive: true); 295 | } 296 | 297 | return def; 298 | } 299 | 300 | Future tryRead({bool create = true, String def = ''}) async { 301 | return exists().then((exists) async { 302 | if (exists) { 303 | return readAsString(); 304 | } else if (create) { 305 | await this.create(recursive: true); 306 | } 307 | 308 | return def; 309 | }); 310 | } 311 | } 312 | 313 | int customHash(dynamic dyn) => CustomHash(dyn).customHash; 314 | 315 | extension CustomHash on dynamic { 316 | int get customHash { 317 | var total = 0; 318 | if (this is Map) { 319 | var map = this as Map; 320 | for (var key in map.keys) { 321 | total ^= CustomHash(key).customHash; 322 | total ^= CustomHash(map[key]).customHash; 323 | } 324 | } else { 325 | total = this.hashCode; 326 | } 327 | 328 | return total; 329 | } 330 | } 331 | 332 | extension IterableUtils on Iterable { 333 | Iterable notNull() => where((value) => value != null); 334 | 335 | /// Asynchronously maps the [mapper] function to the future of [T]. This 336 | /// returns a new List as a result. 337 | Future> aMap(FutureOr Function(E e) mapper) async { 338 | var res = []; 339 | for (var t in this) { 340 | res.add(await mapper(t)); 341 | } 342 | return res; 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: Spogit 2 | description: A sample command-line application. 3 | # version: 1.0.0 4 | # homepage: https://www.example.com 5 | # author: RubbaBoy 6 | 7 | environment: 8 | sdk: '>=2.6.0 <3.0.0' 9 | 10 | dependencies: 11 | args: ^1.6.0 12 | shelf: ^0.7.5 13 | http: ^0.12.0+4 14 | webdriver: ^2.1.2 15 | logging: ^0.11.4 16 | crypto: ^2.1.4 17 | msgpack2: ^2.0.0 18 | tuple: ^1.0.3 19 | intl: ^0.16.1 20 | 21 | dev_dependencies: 22 | pedantic: ^1.8.0 23 | test: ^1.6.0 24 | --------------------------------------------------------------------------------