├── .gitignore ├── .travis.yml ├── README.md ├── deploy_rsa.enc ├── docker-compose.yml ├── elm ├── .gitignore ├── .travis.yml ├── .vscode │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── README.md ├── dist │ └── config.js ├── elm.json ├── index.html ├── index.js ├── package.json ├── review │ ├── ReviewConfig.elm │ └── elm.json ├── src │ ├── Album │ │ ├── Fetch.elm │ │ ├── Select.elm │ │ ├── Types.elm │ │ └── Update.elm │ ├── Artist │ │ ├── Fetch.elm │ │ ├── Select.elm │ │ └── Types.elm │ ├── Audio │ │ ├── Actions.elm │ │ ├── Model.elm │ │ ├── Msg.elm │ │ ├── Request.elm │ │ ├── Select.elm │ │ ├── State.elm │ │ └── Update.elm │ ├── Cache.elm │ ├── Config.elm │ ├── DTO │ │ ├── Authenticate.elm │ │ ├── Credentials.elm │ │ └── Ticket.elm │ ├── Entities │ │ ├── Album.elm │ │ ├── AlbumSummary.elm │ │ ├── Artist.elm │ │ ├── ArtistSummary.elm │ │ ├── Playlist.elm │ │ ├── PlaylistSummary.elm │ │ └── SongSummary.elm │ ├── Loadable.elm │ ├── Main.elm │ ├── Model.elm │ ├── Msg.elm │ ├── Nexus │ │ ├── Callback.elm │ │ ├── Fetch.elm │ │ └── Model.elm │ ├── Player │ │ ├── Actions.elm │ │ ├── Model.elm │ │ ├── Msg.elm │ │ ├── Repeat.elm │ │ ├── Select.elm │ │ └── Update.elm │ ├── Playlist │ │ ├── Fetch.elm │ │ ├── Select.elm │ │ ├── Types.elm │ │ └── Update.elm │ ├── Ports.elm │ ├── Rest │ │ └── Core.elm │ ├── Routing.elm │ ├── Socket │ │ ├── Actions.elm │ │ ├── Core.elm │ │ ├── DTO │ │ │ ├── Album.elm │ │ │ ├── AlbumSummary.elm │ │ │ ├── Artist.elm │ │ │ ├── ArtistSummary.elm │ │ │ ├── Playlist.elm │ │ │ └── SongSummary.elm │ │ ├── Listener.elm │ │ ├── Listeners │ │ │ └── ScanStatus.elm │ │ ├── Message.elm │ │ ├── MessageId.elm │ │ ├── Methods │ │ │ ├── GetAlbums.elm │ │ │ ├── GetArtists.elm │ │ │ ├── GetPlaylists.elm │ │ │ ├── Handshake.elm │ │ │ ├── Start.elm │ │ │ └── StartScan.elm │ │ ├── Model.elm │ │ ├── Notification.elm │ │ ├── NotificationListener.elm │ │ ├── Request.elm │ │ ├── RequestData.elm │ │ ├── Response.elm │ │ ├── Select.elm │ │ ├── SocketMsg.elm │ │ └── Update.elm │ ├── Song │ │ ├── Select.elm │ │ └── Types.elm │ ├── Types.elm │ ├── Updaters.elm │ ├── Util.elm │ ├── Views │ │ ├── Album.elm │ │ ├── Albums.elm │ │ ├── Artist.elm │ │ ├── Artists.elm │ │ ├── Home.elm │ │ ├── Login.elm │ │ ├── MiniAlbum.elm │ │ ├── Player.elm │ │ ├── Playlist.elm │ │ ├── PlaylistItem.elm │ │ ├── Playlists.elm │ │ ├── Root.elm │ │ ├── Sidebar.elm │ │ └── Song.elm │ ├── css │ │ └── reset.css │ └── sass │ │ ├── _album.scss │ │ ├── _app.scss │ │ ├── _home.scss │ │ ├── _login.scss │ │ ├── _player.scss │ │ ├── _playlist.scss │ │ ├── artist.scss │ │ └── styles.scss ├── webpack.common.js ├── webpack.dev.js ├── webpack.home.js ├── webpack.live.js ├── webpack.prod.js └── yarn.lock ├── go ├── .gitignore ├── .vscode │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── api │ ├── api │ │ ├── api.go │ │ ├── handlerFactory.go │ │ ├── response.go │ │ └── spa.go │ ├── controller │ │ ├── art.go │ │ ├── authenticate.go │ │ ├── authenticate_test.go │ │ ├── stream.go │ │ └── ticket.go │ └── dto │ │ ├── credentials.go │ │ ├── ticket.go │ │ └── token.go ├── config.yaml ├── config │ ├── config.go │ └── config_test.go ├── dal │ ├── dal.go │ ├── dal_test.go │ └── mock.go ├── dao │ ├── album.go │ ├── albumList2Type.go │ ├── art.go │ ├── artist.go │ ├── errNotFound.go │ ├── genre.go │ ├── playlist.go │ ├── playlistEntry.go │ └── song.go ├── database │ ├── default.go │ ├── default_test.go │ └── mock.go ├── egs │ ├── getAlbum.json │ ├── getAlbumList2.json │ ├── getArtists.json │ ├── getIndexes.json │ ├── getMusicDirectory.json │ ├── getPlaylist.xml │ ├── getSongsByGenre.json │ └── getplaylists.json ├── entities │ └── fileData.go ├── go.mod ├── go.sum ├── hasher │ └── hasher.go ├── linux_dsm_build.sh ├── log-config.xml ├── main.go ├── migrations │ ├── 000001_initial.down.sql │ ├── 000001_intial.up.sql │ ├── 000002_add_playlist_owner.down.sql │ └── 000002_add_playlist_owner.up.sql ├── projectpath │ └── Root.go ├── provider │ ├── beetsProvider.go │ ├── beetsProvider_test.go │ ├── fsProvider.go │ ├── mockProvider.go │ ├── provider.go │ ├── scanner.go │ └── scanner_test.go ├── services │ ├── authenticator.go │ ├── authenticator_test.go │ ├── clock.go │ ├── fileScanner.go │ ├── fileScanner_test.go │ ├── resizer.go │ ├── tagger.go │ └── tagger_test.go ├── socket │ ├── client.go │ ├── connection.go │ ├── dto │ │ ├── album.go │ │ ├── albumCollection.go │ │ ├── albumSummary.go │ │ ├── artist.go │ │ ├── artistCollection.go │ │ ├── notification.go │ │ ├── playlist.go │ │ ├── playlistCollection.go │ │ ├── request.go │ │ ├── response.go │ │ ├── songSummary.go │ │ └── ticketResponse.go │ ├── handler.go │ ├── handlers │ │ ├── getAlbum.go │ │ ├── getAlbums.go │ │ ├── getArtist.go │ │ ├── getArtists.go │ │ ├── getPlaylist.go │ │ ├── getPlaylists.go │ │ └── startScan.go │ ├── hub.go │ ├── mockHub.go │ └── ticketer.go ├── sound.conf ├── subsonic │ ├── api │ │ ├── api.go │ │ ├── format.go │ │ ├── handlerFactory.go │ │ ├── parse.go │ │ ├── parse_test.go │ │ ├── response.go │ │ ├── serialiser.go │ │ └── serialiser_test.go │ ├── dto │ │ ├── album.go │ │ ├── albumDirectory.go │ │ ├── albumList2.go │ │ ├── albumWithSongs.go │ │ ├── album_test.go │ │ ├── artist.go │ │ ├── artistCollection.go │ │ ├── artistCollection_test.go │ │ ├── artistDirectory.go │ │ ├── artistWithAlbums.go │ │ ├── artist_test.go │ │ ├── data_test.go │ │ ├── directory.go │ │ ├── directoryID.go │ │ ├── error.go │ │ ├── genres.go │ │ ├── genres_test.go │ │ ├── license.go │ │ ├── musicFolderCollection.go │ │ ├── playlist.go │ │ ├── playlistCollection.go │ │ ├── playlistCollection_test.go │ │ ├── playlist_test.go │ │ ├── randomSongs.go │ │ ├── randomSongs_test.go │ │ ├── scanStatus.go │ │ ├── scanStatus_test.go │ │ ├── search.go │ │ ├── search_test.go │ │ ├── song.go │ │ ├── songDirectory.go │ │ ├── song_test.go │ │ ├── songsByGenre.go │ │ ├── user.go │ │ ├── userCollection.go │ │ └── user_test.go │ ├── handler │ │ ├── createPlaylist.go │ │ ├── createPlaylist_test.go │ │ ├── deletePlaylist.go │ │ ├── deletePlaylist_test.go │ │ ├── download.go │ │ ├── getAlbum.go │ │ ├── getAlbumList2.go │ │ ├── getAlbum_test.go │ │ ├── getArtist.go │ │ ├── getArtist_test.go │ │ ├── getArtists.go │ │ ├── getArtists_test.go │ │ ├── getCoverArt.go │ │ ├── getGenres.go │ │ ├── getIndexes.go │ │ ├── getLicense.go │ │ ├── getMusicDirectory.go │ │ ├── getMusicDirectory_test.go │ │ ├── getMusicFolders.go │ │ ├── getPlaylist.go │ │ ├── getPlaylists.go │ │ ├── getRandomSongs.go │ │ ├── getRandomSongs_test.go │ │ ├── getSong.go │ │ ├── getSong_test.go │ │ ├── getSongsByGenre.go │ │ ├── getSongsByGenre_test.go │ │ ├── getUser.go │ │ ├── getUsers.go │ │ ├── ping.go │ │ ├── ping_test.go │ │ ├── scan.go │ │ ├── search.go │ │ ├── search_test.go │ │ ├── star.go │ │ ├── stream.go │ │ ├── updatePlaylist.go │ │ └── updatePlaylist_test.go │ └── routes │ │ └── routes.go ├── testdata │ ├── beetslib.blb │ ├── config.yaml │ ├── dao │ │ ├── albums.yml │ │ ├── artists.yml │ │ ├── arts.yml │ │ ├── genres.yml │ │ ├── playlist_entries.yml │ │ ├── playlists.yml │ │ └── songs.yml │ └── music │ │ ├── 1.mp3 │ │ ├── 2.mp3 │ │ ├── lost.txt │ │ └── subfolder │ │ └── 3.mp3 ├── todo.txt └── util │ ├── util.go │ └── util_test.go └── sound.code-workspace /.gitignore: -------------------------------------------------------------------------------- 1 | .data/**/* 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | addons: 2 | ssh_known_hosts: hednowley.synology.me:169 3 | 4 | services: 5 | - postgresql 6 | - docker 7 | 8 | before_deploy: 9 | - openssl aes-256-cbc -K $encrypted_8c047e66c105_key -iv $encrypted_8c047e66c105_iv -in ../deploy_rsa.enc -out /tmp/deploy_rsa -d 10 | - eval "$(ssh-agent -s)" 11 | - chmod 600 /tmp/deploy_rsa 12 | - ssh-add /tmp/deploy_rsa 13 | 14 | jobs: 15 | include: 16 | - language: go 17 | go: 18 | - 1.14.x 19 | 20 | before_install: 21 | - cd go 22 | - go get github.com/mattn/goveralls 23 | 24 | before_script: 25 | - psql -c "CREATE USER sound WITH PASSWORD 'sound';" -U postgres 26 | - psql -c "ALTER USER sound WITH SUPERUSER;" -U postgres 27 | - psql -c "CREATE DATABASE sound_test OWNER sound;" -U postgres 28 | 29 | script: 30 | - "$GOPATH/bin/goveralls -service=travis-ci" 31 | 32 | deploy: 33 | provider: script 34 | skip_cleanup: true 35 | script: ./linux_dsm_build.sh && 36 | ssh admin@hednowley.synology.me -p 169 "if ( sudo status sound | grep start ); then sudo stop sound; fi" && 37 | scp -P 169 ./sound admin@hednowley.synology.me:/volume1/other/soundDir && 38 | ssh admin@hednowley.synology.me -p 169 "sudo start sound" 39 | 40 | - language: node_js 41 | node_js: 42 | - 12 43 | 44 | before_install: 45 | - cd elm 46 | 47 | script: 48 | - echo "skipping tests" 49 | 50 | deploy: 51 | provider: script 52 | skip_cleanup: true 53 | script: yarn run build:prod && scp -P 169 ./dist/* admin@hednowley.synology.me:/volume1/other/soundDir/static 54 | -------------------------------------------------------------------------------- /deploy_rsa.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hednowley/sound/3fa1ee0a0b1e3d15d12557429fa674f9e19ee93d/deploy_rsa.enc -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | # Database 5 | postgres: 6 | image: postgres:9.3.22 7 | environment: 8 | - POSTGRES_USER=sound 9 | - POSTGRES_PASSWORD=sound 10 | - POSTGRES_DB=sound_test 11 | volumes: 12 | - ./.data/postgres/sound:/var/lib/postgresql/data 13 | ports: 14 | - 5432:5432 15 | -------------------------------------------------------------------------------- /elm/.gitignore: -------------------------------------------------------------------------------- 1 | # elm-package generated files 2 | elm-stuff 3 | # elm-repl generated files 4 | repl-temp-* 5 | /node_modules 6 | /main.js 7 | /dist/**/* 8 | /dist/* 9 | !/dist/config.js 10 | -------------------------------------------------------------------------------- /elm/.travis.yml: -------------------------------------------------------------------------------- 1 | language: elm 2 | 3 | elm: 4 | - elm0.19.0 5 | 6 | script: 7 | - elm-format --validate . 8 | -------------------------------------------------------------------------------- /elm/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome against localhost", 11 | "url": "http://localhost:8000", 12 | "webRoot": "${workspaceFolder}", 13 | "runtimeArgs": ["--disable-web-security"], // Allow CORS 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /elm/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "elm.compiler": "./node_modules/.bin/elm", 3 | "elm.makeCommand": "./node_modules/.bin/elm-make", 4 | "elm.formatCommand": "./node_modules/.bin/elm-format", 5 | "[elm]": { 6 | "editor.formatOnSave": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /elm/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Format all elm", 8 | "type": "shell", 9 | "command": "elm-format --yes ." 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /elm/README.md: -------------------------------------------------------------------------------- 1 | # sound ui 2 | 3 | [![Build Status](https://travis-ci.org/hednowley/sound-ui-elm.svg?branch=master)](https://travis-ci.org/hednowley/sound-ui-elm) 4 | 5 | A work-in-progress front-end for the [sound](https://github.com/hednowley/sound) music server written in [Elm](https://elm-lang.org). 6 | 7 | ## Prerequisites 8 | 9 | You'll need 10 | 11 | - [Node](https://nodejs.org) LTS 12 | - [sound](https://github.com/hednowley/sound) 13 | 14 | ## Building 15 | 16 | ```shell 17 | $ npm install 18 | $ npm run-script build 19 | ``` 20 | 21 | The static web app assets will be emitted to `./dist` to be deployed wherever you like. 22 | -------------------------------------------------------------------------------- /elm/dist/config.js: -------------------------------------------------------------------------------- 1 | var SOUND_CONFIG = {}; 2 | -------------------------------------------------------------------------------- /elm/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "elm/browser": "1.0.2", 10 | "elm/core": "1.0.2", 11 | "elm/html": "1.0.0", 12 | "elm/http": "2.0.0", 13 | "elm/json": "1.1.3", 14 | "elm/random": "1.0.0", 15 | "elm/time": "1.0.0", 16 | "elm/url": "1.0.0", 17 | "elm-community/random-extra": "3.1.0", 18 | "rtfeldman/elm-css": "16.0.1" 19 | }, 20 | "indirect": { 21 | "Skinney/murmur3": "2.0.8", 22 | "elm/bytes": "1.0.7", 23 | "elm/file": "1.0.1", 24 | "elm/virtual-dom": "1.0.2", 25 | "owanturist/elm-union-find": "1.0.0", 26 | "rtfeldman/elm-hex": "1.0.0" 27 | } 28 | }, 29 | "test-dependencies": { 30 | "direct": {}, 31 | "indirect": {} 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /elm/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
Loading...
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /elm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sound-ui-elm", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "main.js", 6 | "dependencies": {}, 7 | "devDependencies": { 8 | "css-loader": "^3.2.1", 9 | "elm": "0.19.1", 10 | "elm-analyse": "^0.16.5", 11 | "elm-format": "^0.8.2", 12 | "elm-review": "^1.0.1", 13 | "elm-webpack-loader": "^6.0.1", 14 | "fibers": "^4.0.2", 15 | "file-loader": "^5.0.2", 16 | "node-sass": "^4.13.0", 17 | "sass": "^1.23.7", 18 | "sass-loader": "^8.0.0", 19 | "style-loader": "^1.0.1", 20 | "webpack": "^4.41.2", 21 | "webpack-cli": "^3.3.10", 22 | "webpack-dev-server": "^3.9.0", 23 | "webpack-merge": "^4.2.2" 24 | }, 25 | "scripts": { 26 | "build": "webpack --config webpack.dev.js", 27 | "build:prod": "webpack --config webpack.prod.js", 28 | "start": "webpack-dev-server -d --config webpack.dev.js", 29 | "start:home": "webpack-dev-server -d --config webpack.home.js", 30 | "start:live": "webpack-dev-server -d --config webpack.live.js", 31 | "analyse": "elm-analyse -s" 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "git+https://github.com/hednowley/sound-ui-elm.git" 36 | }, 37 | "author": "", 38 | "license": "ISC", 39 | "bugs": { 40 | "url": "https://github.com/hednowley/sound-ui-elm/issues" 41 | }, 42 | "homepage": "https://github.com/hednowley/sound-ui-elm#readme" 43 | } 44 | -------------------------------------------------------------------------------- /elm/review/ReviewConfig.elm: -------------------------------------------------------------------------------- 1 | module ReviewConfig exposing (config) 2 | 3 | {-| Do not rename the ReviewConfig module or the config function, because 4 | `elm-review` will look for these. 5 | 6 | To add packages that contain rules, add them to this review project using 7 | 8 | `elm install author/packagename` 9 | 10 | when inside the directory containing this file. 11 | 12 | -} 13 | 14 | import Review.Rule exposing (Rule) 15 | 16 | 17 | config : List Rule 18 | config = 19 | [] 20 | -------------------------------------------------------------------------------- /elm/review/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "." 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "elm/core": "1.0.2", 10 | "elm/json": "1.1.3", 11 | "jfmengels/elm-review": "1.0.0" 12 | }, 13 | "indirect": { 14 | "elm/html": "1.0.0", 15 | "elm/parser": "1.1.0", 16 | "elm/project-metadata-utils": "1.0.0", 17 | "elm/random": "1.0.0", 18 | "elm/time": "1.0.0", 19 | "elm/url": "1.0.0", 20 | "elm/virtual-dom": "1.0.2", 21 | "elm-community/json-extra": "4.2.0", 22 | "elm-community/list-extra": "8.2.2", 23 | "elm-explorations/test": "1.2.2", 24 | "rtfeldman/elm-hex": "1.0.0", 25 | "rtfeldman/elm-iso8601-date-strings": "1.1.3", 26 | "stil4m/elm-syntax": "7.1.1", 27 | "stil4m/structured-writer": "1.0.2" 28 | } 29 | }, 30 | "test-dependencies": { 31 | "direct": {}, 32 | "indirect": {} 33 | } 34 | } -------------------------------------------------------------------------------- /elm/src/Album/Fetch.elm: -------------------------------------------------------------------------------- 1 | module Album.Fetch exposing (fetchAlbum) 2 | 3 | import Album.Types exposing (AlbumId, getRawAlbumId) 4 | import Entities.Album exposing (Album) 5 | import Loadable exposing (Loadable(..)) 6 | import Model exposing (Model) 7 | import Msg exposing (Msg) 8 | import Nexus.Fetch exposing (fetch) 9 | import Socket.DTO.Album exposing (convert, decode) 10 | import Socket.DTO.SongSummary exposing (convertMany) 11 | import Song.Types exposing (SongId(..), getRawSongId) 12 | import Types exposing (Update) 13 | import Util exposing (insertMany) 14 | 15 | 16 | fetchAlbum : Maybe (Album -> Update Model Msg) -> AlbumId -> Update Model Msg 17 | fetchAlbum maybeCallback = 18 | fetch 19 | getRawAlbumId 20 | "getAlbum" 21 | decode 22 | saveSongs 23 | convert 24 | { get = .albums 25 | , set = \repo -> \m -> { m | albums = repo } 26 | } 27 | maybeCallback 28 | 29 | 30 | saveSongs : Socket.DTO.Album.Album -> Model -> Model 31 | saveSongs album model = 32 | let 33 | songs = 34 | convertMany album.songs 35 | in 36 | { model 37 | | songs = 38 | insertMany 39 | (.id >> getRawSongId) 40 | identity 41 | songs 42 | model.songs 43 | } 44 | -------------------------------------------------------------------------------- /elm/src/Album/Select.elm: -------------------------------------------------------------------------------- 1 | module Album.Select exposing (getAlbum, getAlbumArt, getAlbumSongs) 2 | 3 | import Album.Types exposing (AlbumId, getRawAlbumId) 4 | import Dict 5 | import Entities.Album exposing (Album) 6 | import Entities.SongSummary exposing (SongSummary) 7 | import Loadable exposing (Loadable(..)) 8 | import Model exposing (Model) 9 | import Song.Select exposing (getSong) 10 | 11 | 12 | getAlbum : AlbumId -> Model -> Loadable Album 13 | getAlbum albumId model = 14 | Dict.get (getRawAlbumId albumId) model.nexus.albums |> Maybe.withDefault Absent 15 | 16 | 17 | getAlbumSongs : Album -> Model -> List (Maybe SongSummary) 18 | getAlbumSongs album model = 19 | List.map (getSong model) album.songs 20 | 21 | 22 | 23 | -- |> List.sortBy .track 24 | 25 | 26 | getAlbumArt : Maybe String -> String 27 | getAlbumArt art = 28 | case art of 29 | Nothing -> 30 | "" 31 | 32 | Just id -> 33 | "/api/art?size=120&id=" ++ id 34 | -------------------------------------------------------------------------------- /elm/src/Album/Types.elm: -------------------------------------------------------------------------------- 1 | module Album.Types exposing (AlbumId(..), getRawAlbumId) 2 | 3 | 4 | type AlbumId 5 | = AlbumId Int 6 | 7 | 8 | getRawAlbumId : AlbumId -> Int 9 | getRawAlbumId albumId = 10 | let 11 | (AlbumId raw) = 12 | albumId 13 | in 14 | raw 15 | -------------------------------------------------------------------------------- /elm/src/Album/Update.elm: -------------------------------------------------------------------------------- 1 | module Album.Update exposing (playAlbum) 2 | 3 | import Album.Fetch exposing (fetchAlbum) 4 | import Album.Types exposing (AlbumId) 5 | import Audio.Msg exposing (AudioMsg(..)) 6 | import Entities.Album exposing (Album) 7 | import Loadable exposing (Loadable(..)) 8 | import Model exposing (Model) 9 | import Msg exposing (Msg(..)) 10 | import Player.Actions exposing (replacePlaylist) 11 | import Types exposing (Update) 12 | 13 | 14 | playLoadedAlbum : Album -> Update Model Msg 15 | playLoadedAlbum album = 16 | replacePlaylist album.songs 17 | 18 | 19 | playAlbum : AlbumId -> Update Model Msg 20 | playAlbum albumId = 21 | fetchAlbum 22 | (Just playLoadedAlbum) 23 | albumId 24 | -------------------------------------------------------------------------------- /elm/src/Artist/Fetch.elm: -------------------------------------------------------------------------------- 1 | module Artist.Fetch exposing (fetchArtist) 2 | 3 | import Artist.Types exposing (ArtistId, getRawArtistId) 4 | import Entities.Artist exposing (Artist) 5 | import Loadable exposing (Loadable(..)) 6 | import Model exposing (Model) 7 | import Msg exposing (Msg) 8 | import Nexus.Fetch exposing (fetch) 9 | import Socket.DTO.Artist exposing (convert, decode) 10 | import Song.Types exposing (SongId(..)) 11 | import Types exposing (Update) 12 | 13 | 14 | noOp : Socket.DTO.Artist.Artist -> Model -> Model 15 | noOp _ model = 16 | model 17 | 18 | 19 | fetchArtist : Maybe (Artist -> Update Model Msg) -> ArtistId -> Update Model Msg 20 | fetchArtist maybeCallback = 21 | fetch 22 | getRawArtistId 23 | "getArtist" 24 | decode 25 | noOp 26 | convert 27 | { get = .artists 28 | , set = \repo -> \m -> { m | artists = repo } 29 | } 30 | maybeCallback 31 | -------------------------------------------------------------------------------- /elm/src/Artist/Select.elm: -------------------------------------------------------------------------------- 1 | module Artist.Select exposing (getArtist) 2 | 3 | import Artist.Types exposing (ArtistId, getRawArtistId) 4 | import Dict 5 | import Entities.Artist exposing (Artist) 6 | import Loadable exposing (Loadable(..)) 7 | import Model exposing (Model) 8 | 9 | 10 | getArtist : ArtistId -> Model -> Loadable Artist 11 | getArtist id model = 12 | Dict.get (getRawArtistId id) model.nexus.artists |> Maybe.withDefault Absent 13 | -------------------------------------------------------------------------------- /elm/src/Artist/Types.elm: -------------------------------------------------------------------------------- 1 | module Artist.Types exposing (ArtistId(..), getRawArtistId) 2 | 3 | 4 | type ArtistId 5 | = ArtistId Int 6 | 7 | 8 | getRawArtistId : ArtistId -> Int 9 | getRawArtistId albumId = 10 | let 11 | (ArtistId raw) = 12 | albumId 13 | in 14 | raw 15 | -------------------------------------------------------------------------------- /elm/src/Audio/Model.elm: -------------------------------------------------------------------------------- 1 | module Audio.Model exposing (Model, emptyModel) 2 | 3 | import Audio.State exposing (State) 4 | import Dict exposing (Dict) 5 | 6 | 7 | emptyModel : Model 8 | emptyModel = 9 | { songs = Dict.empty 10 | } 11 | 12 | 13 | type alias Model = 14 | { songs : Dict Int State 15 | } 16 | -------------------------------------------------------------------------------- /elm/src/Audio/Msg.elm: -------------------------------------------------------------------------------- 1 | module Audio.Msg exposing (AudioMsg(..)) 2 | 3 | import Song.Types exposing (SongId) 4 | 5 | 6 | type AudioMsg 7 | = CanPlay SongId -- A song is ready to be played 8 | | Ended SongId 9 | | SetTime Float 10 | | TimeChanged { songId : Int, time : Float } 11 | | Playing { songId : Int, time : Float, duration : Maybe Float } 12 | | Paused { songId : Int, time : Float, duration : Maybe Float } 13 | -------------------------------------------------------------------------------- /elm/src/Audio/Request.elm: -------------------------------------------------------------------------------- 1 | module Audio.Request exposing (LoadRequest, makeLoadRequest) 2 | 3 | import Song.Types exposing (SongId, getRawSongId) 4 | import String exposing (fromInt) 5 | 6 | 7 | type alias LoadRequest = 8 | { url : String 9 | , songId : Int 10 | } 11 | 12 | 13 | makeLoadRequest : SongId -> LoadRequest 14 | makeLoadRequest songId = 15 | { url = "/api/stream?id=" ++ fromInt (getRawSongId songId) 16 | , songId = getRawSongId songId 17 | } 18 | -------------------------------------------------------------------------------- /elm/src/Audio/Select.elm: -------------------------------------------------------------------------------- 1 | module Audio.Select exposing (getSongState) 2 | 3 | import Audio.State exposing (State(..)) 4 | import Dict 5 | import Loadable exposing (Loadable(..)) 6 | import Model exposing (Model) 7 | import Routing exposing (Route(..)) 8 | import Song.Types exposing (SongId(..), getRawSongId) 9 | 10 | 11 | getSongState : Model -> SongId -> Maybe State 12 | getSongState model songId = 13 | Dict.get (getRawSongId songId) model.audio.songs 14 | -------------------------------------------------------------------------------- /elm/src/Audio/State.elm: -------------------------------------------------------------------------------- 1 | module Audio.State exposing (State(..)) 2 | 3 | 4 | type State 5 | = Loading 6 | | Loaded { duration : Maybe Float } 7 | | Playing { time : Float, duration : Maybe Float, paused : Bool } 8 | -------------------------------------------------------------------------------- /elm/src/Audio/Update.elm: -------------------------------------------------------------------------------- 1 | module Audio.Update exposing (update) 2 | 3 | import Audio.Actions exposing (onSongLoaded, onTimeChanged, updateSongState) 4 | import Audio.Msg exposing (AudioMsg(..)) 5 | import Audio.State 6 | import Model exposing (Model) 7 | import Msg exposing (Msg) 8 | import Player.Actions 9 | exposing 10 | ( onSongEnded 11 | , setCurrentTime 12 | ) 13 | import Song.Types exposing (SongId(..)) 14 | import Types exposing (Update) 15 | 16 | 17 | update : AudioMsg -> Update Model Msg 18 | update msg model = 19 | case msg of 20 | CanPlay songId -> 21 | onSongLoaded songId model 22 | 23 | Ended _ -> 24 | onSongEnded model 25 | 26 | SetTime time -> 27 | setCurrentTime time model 28 | 29 | Playing { songId, time, duration } -> 30 | ( updateSongState 31 | (SongId songId) 32 | (Audio.State.Playing { paused = False, time = time, duration = duration }) 33 | model 34 | , Cmd.none 35 | ) 36 | 37 | Paused { songId, time, duration } -> 38 | ( updateSongState 39 | (SongId songId) 40 | (Audio.State.Playing { paused = True, time = time, duration = duration }) 41 | model 42 | , Cmd.none 43 | ) 44 | 45 | TimeChanged args -> 46 | ( onTimeChanged 47 | (SongId args.songId) 48 | args.time 49 | model 50 | , Cmd.none 51 | ) 52 | -------------------------------------------------------------------------------- /elm/src/Cache.elm: -------------------------------------------------------------------------------- 1 | module Cache exposing (Cache, makeCache, makeModel, tryDecode) 2 | 3 | import Json.Decode as Decode exposing (Decoder) 4 | import Loadable exposing (fromMaybe, toMaybe) 5 | import Model exposing (Model) 6 | 7 | 8 | {-| A version of the model which can be stored in the browser. 9 | -} 10 | type alias Cache = 11 | { token : Maybe String } 12 | 13 | 14 | {-| Create a cache from a model. 15 | -} 16 | makeCache : Model -> Cache 17 | makeCache model = 18 | { token = toMaybe model.token } 19 | 20 | 21 | {-| Create a model from a cache. 22 | -} 23 | makeModel : Model -> Maybe Cache -> Model 24 | makeModel default cache = 25 | case cache of 26 | Just c -> 27 | { default | token = fromMaybe c.token } 28 | 29 | Nothing -> 30 | default 31 | 32 | 33 | {-| Try to decode a cache from an optional JSON value. 34 | -} 35 | tryDecode : Maybe Decode.Value -> Maybe Cache 36 | tryDecode = 37 | Maybe.andThen <| Decode.decodeValue decode >> Result.toMaybe 38 | 39 | 40 | decode : Decoder Cache 41 | decode = 42 | Decode.map Cache 43 | (Decode.field "token" (Decode.maybe Decode.string)) 44 | -------------------------------------------------------------------------------- /elm/src/Config.elm: -------------------------------------------------------------------------------- 1 | module Config exposing (Config) 2 | 3 | 4 | type alias Config = 5 | {} 6 | -------------------------------------------------------------------------------- /elm/src/DTO/Authenticate.elm: -------------------------------------------------------------------------------- 1 | module DTO.Authenticate exposing (Response, decode) 2 | 3 | import Json.Decode exposing (Decoder, andThen, fail, field, map, string, succeed) 4 | 5 | 6 | {-| A potential error message. Absense means success. 7 | -} 8 | type alias Response = 9 | Maybe String 10 | 11 | 12 | {-| Decode the message. 13 | -} 14 | decode : Decoder Response 15 | decode = 16 | field "status" string 17 | |> andThen decodeData 18 | 19 | 20 | {-| Decode the data portion of the message. 21 | -} 22 | decodeData : String -> Decoder Response 23 | decodeData status = 24 | case status of 25 | "success" -> 26 | succeed Nothing 27 | 28 | "error" -> 29 | map Just (field "data" string) 30 | 31 | _ -> 32 | fail "Unknown status" 33 | -------------------------------------------------------------------------------- /elm/src/DTO/Credentials.elm: -------------------------------------------------------------------------------- 1 | module DTO.Credentials exposing (credentialsEncoder) 2 | 3 | import Json.Encode 4 | 5 | 6 | credentialsEncoder : String -> String -> Json.Encode.Value 7 | credentialsEncoder username password = 8 | Json.Encode.object 9 | [ ( "username", Json.Encode.string username ) 10 | , ( "password", Json.Encode.string password ) 11 | ] 12 | -------------------------------------------------------------------------------- /elm/src/DTO/Ticket.elm: -------------------------------------------------------------------------------- 1 | module DTO.Ticket exposing (decode) 2 | 3 | import Json.Decode exposing (Decoder, field, string) 4 | 5 | 6 | decode : Decoder String 7 | decode = 8 | field "data" (field "ticket" string) 9 | -------------------------------------------------------------------------------- /elm/src/Entities/Album.elm: -------------------------------------------------------------------------------- 1 | module Entities.Album exposing (Album) 2 | 3 | import Song.Types exposing (SongId) 4 | 5 | 6 | type alias Album = 7 | { id : Int 8 | , artId : Maybe String 9 | , name : String 10 | , songs : List SongId 11 | } 12 | -------------------------------------------------------------------------------- /elm/src/Entities/AlbumSummary.elm: -------------------------------------------------------------------------------- 1 | module Entities.AlbumSummary exposing (AlbumSummaries, AlbumSummary) 2 | 3 | import Album.Types exposing (AlbumId) 4 | import Dict exposing (Dict) 5 | 6 | 7 | type alias AlbumSummary = 8 | { id : AlbumId 9 | , name : String 10 | , duration : Int 11 | , year : Maybe Int 12 | , artId : Maybe String 13 | } 14 | 15 | 16 | type alias AlbumSummaries = 17 | Dict Int AlbumSummary 18 | -------------------------------------------------------------------------------- /elm/src/Entities/Artist.elm: -------------------------------------------------------------------------------- 1 | module Entities.Artist exposing (Artist) 2 | 3 | import Artist.Types exposing (ArtistId) 4 | import Entities.AlbumSummary exposing (AlbumSummary) 5 | 6 | 7 | type alias Artist = 8 | { id : ArtistId 9 | , name : String 10 | , albums : List AlbumSummary 11 | } 12 | -------------------------------------------------------------------------------- /elm/src/Entities/ArtistSummary.elm: -------------------------------------------------------------------------------- 1 | module Entities.ArtistSummary exposing (ArtistSummaries, ArtistSummary) 2 | 3 | import Artist.Types exposing (ArtistId) 4 | import Dict exposing (Dict) 5 | 6 | 7 | type alias ArtistSummary = 8 | { id : ArtistId 9 | , name : String 10 | } 11 | 12 | 13 | type alias ArtistSummaries = 14 | Dict Int ArtistSummary 15 | -------------------------------------------------------------------------------- /elm/src/Entities/Playlist.elm: -------------------------------------------------------------------------------- 1 | module Entities.Playlist exposing (Playlist) 2 | 3 | import Song.Types exposing (SongId) 4 | 5 | 6 | type alias Playlist = 7 | { id : Int 8 | , name : String 9 | , songs : List SongId 10 | } 11 | -------------------------------------------------------------------------------- /elm/src/Entities/PlaylistSummary.elm: -------------------------------------------------------------------------------- 1 | module Entities.PlaylistSummary exposing (PlaylistSummaries, PlaylistSummary) 2 | 3 | import Dict exposing (Dict) 4 | 5 | 6 | type alias PlaylistSummary = 7 | { id : Int 8 | , name : String 9 | } 10 | 11 | 12 | type alias PlaylistSummaries = 13 | Dict Int PlaylistSummary 14 | -------------------------------------------------------------------------------- /elm/src/Entities/SongSummary.elm: -------------------------------------------------------------------------------- 1 | module Entities.SongSummary exposing (SongSummary) 2 | 3 | import Song.Types exposing (SongId) 4 | 5 | 6 | type alias SongSummary = 7 | { id : SongId 8 | , name : String 9 | , track : Int 10 | } 11 | -------------------------------------------------------------------------------- /elm/src/Loadable.elm: -------------------------------------------------------------------------------- 1 | module Loadable exposing (Loadable(..), fromMaybe, toMaybe) 2 | 3 | import Socket.MessageId exposing (MessageId) 4 | 5 | 6 | {-| Something which takes time to load. 7 | -} 8 | type Loadable value 9 | = Absent 10 | | Loading MessageId 11 | | Loaded value 12 | 13 | 14 | {-| Convert a Loadable to a Maybe. 15 | -} 16 | toMaybe : Loadable value -> Maybe value 17 | toMaybe loadable = 18 | case loadable of 19 | Loaded v -> 20 | Just v 21 | 22 | _ -> 23 | Nothing 24 | 25 | 26 | {-| Convert a Maybe into a Loadable. 27 | -} 28 | fromMaybe : Maybe value -> Loadable value 29 | fromMaybe maybe = 30 | case maybe of 31 | Just v -> 32 | Loaded v 33 | 34 | _ -> 35 | Absent 36 | -------------------------------------------------------------------------------- /elm/src/Model.elm: -------------------------------------------------------------------------------- 1 | module Model exposing (Model, SocketModelWrap(..), getSocketModel, setSocketModel) 2 | 3 | import Audio.Model 4 | import Browser.Navigation exposing (Key) 5 | import Config exposing (Config) 6 | import Dict exposing (Dict) 7 | import Entities.AlbumSummary exposing (AlbumSummaries) 8 | import Entities.ArtistSummary exposing (ArtistSummaries) 9 | import Entities.PlaylistSummary exposing (PlaylistSummaries) 10 | import Entities.SongSummary exposing (SongSummary) 11 | import Loadable exposing (Loadable(..)) 12 | import Nexus.Model 13 | import Player.Model 14 | import Routing exposing (Route) 15 | import Socket.Model 16 | import Url exposing (Url) 17 | 18 | 19 | type alias Model = 20 | { key : Key 21 | , url : Url 22 | , username : String 23 | , password : String 24 | , message : String 25 | , token : Loadable String 26 | , isScanning : Bool 27 | , scanCount : Int 28 | , scanShouldUpdate : Bool 29 | , scanShouldDelete : Bool 30 | , playlists : PlaylistSummaries 31 | , nexus : Nexus.Model.Model 32 | , artists : ArtistSummaries 33 | , albums : AlbumSummaries 34 | , songs : Dict Int SongSummary 35 | , config : Config 36 | , route : Maybe Route 37 | , socket : SocketModelWrap 38 | , audio : Audio.Model.Model 39 | , player : Player.Model.Model 40 | } 41 | 42 | 43 | {-| Type to avoid type recursion 44 | -} 45 | type SocketModelWrap 46 | = SocketModelWrap (Socket.Model.Model Model) 47 | 48 | 49 | getSocketModel : Model -> Socket.Model.Model Model 50 | getSocketModel model = 51 | let 52 | (SocketModelWrap s) = 53 | model.socket 54 | in 55 | s 56 | 57 | 58 | setSocketModel : Model -> Socket.Model.Model Model -> Model 59 | setSocketModel model socket = 60 | { model 61 | | socket = SocketModelWrap socket 62 | } 63 | -------------------------------------------------------------------------------- /elm/src/Msg.elm: -------------------------------------------------------------------------------- 1 | module Msg exposing (Msg(..)) 2 | 3 | import Audio.Msg exposing (AudioMsg) 4 | import Browser 5 | import DTO.Authenticate 6 | import Http 7 | import Player.Msg exposing (PlayerMsg) 8 | import Socket.SocketMsg exposing (SocketMsg) 9 | import Url 10 | 11 | 12 | type Msg 13 | = OnUrlChange Url.Url 14 | | OnUrlRequest Browser.UrlRequest 15 | | UsernameChanged String 16 | | PasswordChanged String 17 | | SubmitLogin 18 | | LogOut 19 | | ToggleScanUpdate 20 | | ToggleScanDelete 21 | | StartScan -- Ask for a scan to be started 22 | | GotAuthenticateResponse (Result Http.Error DTO.Authenticate.Response) -- Server has replied to posting of credentials 23 | | GotTicketResponse (Result Http.Error String) -- Server has replied to a request for a websocket ticket 24 | | SocketMsg SocketMsg 25 | | PlayerMsg PlayerMsg 26 | | AudioMsg AudioMsg 27 | -------------------------------------------------------------------------------- /elm/src/Nexus/Callback.elm: -------------------------------------------------------------------------------- 1 | module Nexus.Callback exposing (Callback, resolve) 2 | 3 | import Loadable exposing (Loadable(..)) 4 | import Model exposing (Model) 5 | import Msg exposing (Msg) 6 | import Types exposing (Update) 7 | 8 | 9 | type alias Callback a = 10 | a -> Update Model Msg 11 | 12 | 13 | resolve : Maybe (Callback a) -> Callback a 14 | resolve maybeCallback = 15 | Maybe.withDefault 16 | (\a -> \m -> ( m, Cmd.none )) 17 | maybeCallback 18 | -------------------------------------------------------------------------------- /elm/src/Nexus/Model.elm: -------------------------------------------------------------------------------- 1 | module Nexus.Model exposing (Model, empty) 2 | 3 | import Dict exposing (Dict) 4 | import Entities.Album exposing (Album) 5 | import Entities.Artist exposing (Artist) 6 | import Entities.Playlist exposing (Playlist) 7 | import Loadable exposing (Loadable(..)) 8 | 9 | 10 | type alias Model = 11 | { playlists : Dict Int (Loadable Playlist) 12 | , artists : Dict Int (Loadable Artist) 13 | , albums : Dict Int (Loadable Album) 14 | } 15 | 16 | 17 | empty : Model 18 | empty = 19 | { playlists = Dict.empty 20 | , artists = Dict.empty 21 | , albums = Dict.empty 22 | } 23 | -------------------------------------------------------------------------------- /elm/src/Player/Model.elm: -------------------------------------------------------------------------------- 1 | module Player.Model exposing (Model, emptyModel) 2 | 3 | import Array exposing (Array) 4 | import Player.Repeat exposing (Repeat(..)) 5 | import Song.Types exposing (SongId) 6 | 7 | 8 | emptyModel : Model 9 | emptyModel = 10 | { shuffle = False 11 | , repeat = None 12 | , playlist = Array.empty 13 | , unshuffledPlaylist = Array.empty 14 | , playing = Nothing 15 | , isPaused = True 16 | } 17 | 18 | 19 | type alias Model = 20 | { shuffle : Bool 21 | , repeat : Repeat 22 | , playlist : Array SongId 23 | , unshuffledPlaylist : Array SongId 24 | , playing : Maybe Int 25 | , isPaused : Bool 26 | } 27 | -------------------------------------------------------------------------------- /elm/src/Player/Msg.elm: -------------------------------------------------------------------------------- 1 | module Player.Msg exposing (PlayerMsg(..)) 2 | 3 | import Album.Types exposing (AlbumId) 4 | import Array exposing (Array) 5 | import Player.Repeat exposing (Repeat) 6 | import Playlist.Types exposing (PlaylistId) 7 | import Song.Types exposing (SongId) 8 | 9 | 10 | type PlayerMsg 11 | = Play SongId 12 | | PlayItem Int 13 | | Pause 14 | | Resume 15 | | Queue SongId 16 | | PlayAlbum AlbumId 17 | | PlayPlaylist PlaylistId 18 | | Next 19 | | Prev 20 | | SetShuffle Bool 21 | | Shuffled (Array SongId) 22 | | SetRepeat Repeat 23 | -------------------------------------------------------------------------------- /elm/src/Player/Repeat.elm: -------------------------------------------------------------------------------- 1 | module Player.Repeat exposing (Repeat(..)) 2 | 3 | 4 | type Repeat 5 | = None 6 | | One 7 | | All 8 | -------------------------------------------------------------------------------- /elm/src/Player/Select.elm: -------------------------------------------------------------------------------- 1 | module Player.Select exposing (getCurrentSongId, getCurrentSongState, getSongId, isPlaying, shuffleIsOn) 2 | 3 | import Array 4 | import Audio.Select exposing (getSongState) 5 | import Audio.State exposing (State(..)) 6 | import Loadable exposing (Loadable(..)) 7 | import Model exposing (Model) 8 | import Routing exposing (Route(..)) 9 | import Song.Types exposing (SongId(..)) 10 | 11 | 12 | {-| Gets the ID of the song at the given position in the playlist. 13 | -} 14 | getSongId : Model -> Int -> Maybe SongId 15 | getSongId model index = 16 | Array.get index model.player.playlist 17 | 18 | 19 | {-| Gets the ID of the currently playing song. 20 | -} 21 | getCurrentSongId : Model -> Maybe SongId 22 | getCurrentSongId model = 23 | model.player.playing 24 | |> Maybe.andThen (getSongId model) 25 | 26 | 27 | getCurrentSongState : Model -> Maybe State 28 | getCurrentSongState model = 29 | getCurrentSongId model |> Maybe.andThen (getSongState model) 30 | 31 | 32 | shuffleIsOn : Model -> Bool 33 | shuffleIsOn model = 34 | model.player.shuffle 35 | 36 | 37 | isPlaying : Model -> Bool 38 | isPlaying model = 39 | case getCurrentSongState model of 40 | Just (Playing _) -> 41 | True 42 | 43 | _ -> 44 | False 45 | -------------------------------------------------------------------------------- /elm/src/Player/Update.elm: -------------------------------------------------------------------------------- 1 | module Player.Update exposing (update) 2 | 3 | import Album.Update exposing (playAlbum) 4 | import Audio.Msg exposing (AudioMsg(..)) 5 | import Model exposing (Model) 6 | import Msg 7 | import Player.Actions 8 | exposing 9 | ( finishShufflePlaylist 10 | , goNext 11 | , goPrev 12 | , pauseCurrent 13 | , playItem 14 | , queueAndPlaySong 15 | , queueSong 16 | , resumeCurrent 17 | , setRepeat 18 | , setShuffle 19 | ) 20 | import Player.Msg exposing (PlayerMsg(..)) 21 | import Playlist.Update exposing (playPlaylist) 22 | import Song.Types exposing (SongId(..)) 23 | import Types exposing (Update) 24 | 25 | 26 | update : PlayerMsg -> Update Model Msg.Msg 27 | update msg model = 28 | case msg of 29 | PlayItem index -> 30 | playItem index model 31 | 32 | Play songId -> 33 | queueAndPlaySong songId model 34 | 35 | PlayAlbum albumId -> 36 | playAlbum albumId model 37 | 38 | PlayPlaylist playlistId -> 39 | playPlaylist playlistId model 40 | 41 | Pause -> 42 | pauseCurrent model 43 | 44 | Resume -> 45 | resumeCurrent model 46 | 47 | Queue songId -> 48 | ( queueSong songId model, Cmd.none ) 49 | 50 | Next -> 51 | goNext model 52 | 53 | Prev -> 54 | goPrev model 55 | 56 | SetShuffle on -> 57 | setShuffle on model 58 | 59 | Shuffled playlist -> 60 | ( finishShufflePlaylist playlist model, Cmd.none ) 61 | 62 | SetRepeat repeat -> 63 | setRepeat repeat model 64 | -------------------------------------------------------------------------------- /elm/src/Playlist/Fetch.elm: -------------------------------------------------------------------------------- 1 | module Playlist.Fetch exposing (fetchPlaylist) 2 | 3 | import Entities.Playlist exposing (Playlist) 4 | import Loadable exposing (Loadable(..)) 5 | import Model exposing (Model) 6 | import Msg exposing (Msg) 7 | import Nexus.Fetch exposing (fetch) 8 | import Playlist.Types exposing (PlaylistId, getRawPlaylistId) 9 | import Socket.DTO.Playlist exposing (convert, decode) 10 | import Socket.DTO.SongSummary exposing (convertMany) 11 | import Song.Types exposing (SongId(..), getRawSongId) 12 | import Types exposing (Update) 13 | import Util exposing (insertMany) 14 | 15 | 16 | fetchPlaylist : Maybe (Playlist -> Update Model Msg) -> PlaylistId -> Update Model Msg 17 | fetchPlaylist maybeCallback = 18 | fetch 19 | getRawPlaylistId 20 | "getPlaylist" 21 | decode 22 | saveSongs 23 | convert 24 | { get = .playlists 25 | , set = \repo -> \m -> { m | playlists = repo } 26 | } 27 | maybeCallback 28 | 29 | 30 | saveSongs : Socket.DTO.Playlist.Playlist -> Model -> Model 31 | saveSongs playlist model = 32 | let 33 | songs = 34 | convertMany playlist.songs 35 | in 36 | { model 37 | | songs = 38 | insertMany 39 | (.id >> getRawSongId) 40 | identity 41 | songs 42 | model.songs 43 | } 44 | -------------------------------------------------------------------------------- /elm/src/Playlist/Select.elm: -------------------------------------------------------------------------------- 1 | module Playlist.Select exposing (getPlaylist, getPlaylistSongs) 2 | 3 | import Dict 4 | import Entities.Playlist exposing (Playlist) 5 | import Entities.SongSummary exposing (SongSummary) 6 | import Loadable exposing (Loadable(..)) 7 | import Model exposing (Model) 8 | import Playlist.Types exposing (PlaylistId, getRawPlaylistId) 9 | import Song.Select exposing (getSong) 10 | 11 | 12 | getPlaylist : PlaylistId -> Model -> Loadable Playlist 13 | getPlaylist id model = 14 | Dict.get (getRawPlaylistId id) model.nexus.playlists |> Maybe.withDefault Absent 15 | 16 | 17 | getPlaylistSongs : Playlist -> Model -> List (Maybe SongSummary) 18 | getPlaylistSongs playlist model = 19 | List.map (getSong model) playlist.songs 20 | -------------------------------------------------------------------------------- /elm/src/Playlist/Types.elm: -------------------------------------------------------------------------------- 1 | module Playlist.Types exposing (PlaylistId(..), getRawPlaylistId) 2 | 3 | 4 | type PlaylistId 5 | = PlaylistId Int 6 | 7 | 8 | getRawPlaylistId : PlaylistId -> Int 9 | getRawPlaylistId playlistId = 10 | let 11 | (PlaylistId raw) = 12 | playlistId 13 | in 14 | raw 15 | -------------------------------------------------------------------------------- /elm/src/Playlist/Update.elm: -------------------------------------------------------------------------------- 1 | module Playlist.Update exposing (playPlaylist) 2 | 3 | import Audio.Msg exposing (AudioMsg(..)) 4 | import Entities.Playlist exposing (Playlist) 5 | import Loadable exposing (Loadable(..)) 6 | import Model exposing (Model) 7 | import Msg exposing (Msg(..)) 8 | import Player.Actions exposing (replacePlaylist) 9 | import Playlist.Fetch exposing (fetchPlaylist) 10 | import Playlist.Types exposing (PlaylistId) 11 | import Types exposing (Update) 12 | 13 | 14 | playLoadedPlaylist : Playlist -> Update Model Msg 15 | playLoadedPlaylist playlist = 16 | replacePlaylist playlist.songs 17 | 18 | 19 | playPlaylist : PlaylistId -> Update Model Msg 20 | playPlaylist playlistId = 21 | fetchPlaylist 22 | (Just playLoadedPlaylist) 23 | playlistId 24 | -------------------------------------------------------------------------------- /elm/src/Ports.elm: -------------------------------------------------------------------------------- 1 | port module Ports exposing (..) 2 | 3 | import Audio.Request 4 | import Cache exposing (Cache) 5 | import Json.Encode 6 | 7 | 8 | 9 | -- Outgoing ports 10 | 11 | 12 | port setCache : Cache -> Cmd msg 13 | 14 | 15 | port loadAudio : Audio.Request.LoadRequest -> Cmd msg 16 | 17 | 18 | port playAudio : Int -> Cmd msg 19 | 20 | 21 | port pauseAudio : Int -> Cmd msg 22 | 23 | 24 | port resumeAudio : Int -> Cmd msg 25 | 26 | 27 | port setAudioTime : { songId : Int, time : Float } -> Cmd msg 28 | 29 | 30 | port websocketOut : Json.Encode.Value -> Cmd msg 31 | 32 | 33 | port websocketOpen : String -> Cmd msg 34 | 35 | 36 | port websocketClose : () -> Cmd msg 37 | 38 | 39 | 40 | -- Incoming ports 41 | 42 | 43 | port canPlayAudio : (Int -> msg) -> Sub msg 44 | 45 | 46 | port audioEnded : (Int -> msg) -> Sub msg 47 | 48 | 49 | port audioPlaying : ({ songId : Int, time : Float, duration : Maybe Float } -> msg) -> Sub msg 50 | 51 | 52 | port audioPaused : ({ songId : Int, time : Float, duration : Maybe Float } -> msg) -> Sub msg 53 | 54 | 55 | port audioTimeChanged : ({ songId : Int, time : Float } -> msg) -> Sub msg 56 | 57 | 58 | port audioNextPressed : (() -> msg) -> Sub msg 59 | 60 | 61 | port audioPrevPressed : (() -> msg) -> Sub msg 62 | 63 | 64 | port websocketOpened : (() -> msg) -> Sub msg 65 | 66 | 67 | port websocketClosed : (() -> msg) -> Sub msg 68 | 69 | 70 | port websocketIn : (String -> msg) -> Sub msg 71 | -------------------------------------------------------------------------------- /elm/src/Routing.elm: -------------------------------------------------------------------------------- 1 | module Routing exposing (Route(..), getWebsocketUrl, parseUrl) 2 | 3 | import Album.Types exposing (AlbumId(..)) 4 | import Artist.Types exposing (ArtistId(..)) 5 | import Playlist.Types exposing (PlaylistId(..)) 6 | import String exposing (fromInt) 7 | import Url exposing (Url) 8 | import Url.Parser exposing ((), Parser, int, map, oneOf, parse, s) 9 | 10 | 11 | getWebsocketUrl : Url -> String 12 | getWebsocketUrl url = 13 | let 14 | port_ = 15 | case url.port_ of 16 | Just p -> 17 | ":" ++ fromInt p 18 | 19 | Nothing -> 20 | "" 21 | in 22 | "ws://" ++ url.host ++ port_ ++ "/ws" 23 | 24 | 25 | type Route 26 | = Artist ArtistId 27 | | Album AlbumId 28 | | Playlist PlaylistId 29 | | Artists 30 | | Albums 31 | | Playlists 32 | 33 | 34 | parseUrl : Url -> Maybe Route 35 | parseUrl = 36 | parse routeParser 37 | 38 | 39 | routeParser : Parser (Route -> a) a 40 | routeParser = 41 | oneOf 42 | [ map (ArtistId >> Artist) (s "artist" int) 43 | , map (AlbumId >> Album) (s "album" int) 44 | , map (PlaylistId >> Playlist) (s "playlist" int) 45 | , map Artists (s "artists") 46 | , map Albums (s "albums") 47 | , map Playlists (s "playlists") 48 | ] 49 | -------------------------------------------------------------------------------- /elm/src/Socket/Actions.elm: -------------------------------------------------------------------------------- 1 | module Socket.Actions exposing (addListener, addListenerExternal, removeListener) 2 | 3 | import Dict 4 | import Model exposing (getSocketModel, setSocketModel) 5 | import Msg exposing (Msg) 6 | import Socket.Listener exposing (Listener, combineListeners) 7 | import Socket.MessageId exposing (MessageId, getRawMessageId) 8 | import Socket.Model exposing (Model) 9 | import Socket.Select exposing (getListener) 10 | 11 | 12 | type alias Model = 13 | Socket.Model.Model Model.Model 14 | 15 | 16 | addListenerExternal : MessageId -> Listener Model.Model Msg -> Model.Model -> Model.Model 17 | addListenerExternal id listener model = 18 | let 19 | socket = 20 | getSocketModel model 21 | in 22 | addListener id listener socket |> setSocketModel model 23 | 24 | 25 | {-| Store a new Websocket listener in the model. 26 | -} 27 | addListener : MessageId -> Listener Model.Model Msg -> Model -> Model 28 | addListener id listener model = 29 | case getListener id model of 30 | Just existing -> 31 | let 32 | combined = 33 | combineListeners existing listener 34 | in 35 | insertListener id combined model 36 | 37 | Nothing -> 38 | insertListener id listener model 39 | 40 | 41 | insertListener : MessageId -> Listener Model.Model Msg -> Model -> Model 42 | insertListener messageId listener model = 43 | { model 44 | | listeners = 45 | Dict.insert (getRawMessageId messageId) listener model.listeners 46 | } 47 | 48 | 49 | {-| Remove a stored Websocket listener from the model. 50 | -} 51 | removeListener : Int -> Model -> Model 52 | removeListener id model = 53 | { model 54 | | listeners = 55 | Dict.remove id model.listeners 56 | } 57 | -------------------------------------------------------------------------------- /elm/src/Socket/DTO/Album.elm: -------------------------------------------------------------------------------- 1 | module Socket.DTO.Album exposing (Album, convert, decode) 2 | 3 | import Entities.Album 4 | import Json.Decode exposing (Decoder, field, int, list, map6, maybe, string) 5 | import Socket.DTO.SongSummary exposing (SongSummary) 6 | import Song.Types exposing (SongId(..)) 7 | 8 | 9 | type alias Album = 10 | { id : Int 11 | , artId : Maybe String 12 | , name : String 13 | , duration : Int 14 | , year : Maybe Int 15 | , songs : List SongSummary 16 | } 17 | 18 | 19 | decode : Decoder Album 20 | decode = 21 | map6 Album 22 | (field "id" int) 23 | (maybe <| field "coverArt" string) 24 | (field "name" string) 25 | (field "duration" int) 26 | (maybe <| field "year" int) 27 | (field "songs" <| list Socket.DTO.SongSummary.decode) 28 | 29 | 30 | convert : Album -> Entities.Album.Album 31 | convert album = 32 | { id = album.id 33 | , artId = album.artId 34 | , name = album.name 35 | , songs = List.map (.id >> SongId) album.songs 36 | } 37 | -------------------------------------------------------------------------------- /elm/src/Socket/DTO/AlbumSummary.elm: -------------------------------------------------------------------------------- 1 | module Socket.DTO.AlbumSummary exposing (AlbumSummary, convert, decode) 2 | 3 | import Album.Types exposing (AlbumId(..)) 4 | import Entities.AlbumSummary 5 | import Json.Decode exposing (Decoder, field, int, map5, maybe, string) 6 | 7 | 8 | type alias AlbumSummary = 9 | { id : Int 10 | , name : String 11 | , duration : Int 12 | , year : Maybe Int 13 | , artId : Maybe String 14 | } 15 | 16 | 17 | decode : Decoder AlbumSummary 18 | decode = 19 | map5 AlbumSummary 20 | (field "id" int) 21 | (field "name" string) 22 | (field "duration" int) 23 | (maybe <| field "year" int) 24 | (maybe <| field "coverArt" string) 25 | 26 | 27 | convert : AlbumSummary -> Entities.AlbumSummary.AlbumSummary 28 | convert album = 29 | { id = AlbumId album.id 30 | , name = album.name 31 | , duration = album.duration 32 | , year = album.year 33 | , artId = album.artId 34 | } 35 | -------------------------------------------------------------------------------- /elm/src/Socket/DTO/Artist.elm: -------------------------------------------------------------------------------- 1 | module Socket.DTO.Artist exposing (Artist, convert, decode) 2 | 3 | import Artist.Types exposing (ArtistId(..)) 4 | import Entities.Artist 5 | import Json.Decode exposing (Decoder, field, int, list, map3, string) 6 | import Socket.DTO.AlbumSummary exposing (AlbumSummary) 7 | 8 | 9 | type alias Artist = 10 | { id : Int 11 | , name : String 12 | , albums : List AlbumSummary 13 | } 14 | 15 | 16 | decode : Decoder Artist 17 | decode = 18 | map3 Artist 19 | (field "id" int) 20 | (field "name" string) 21 | (field "albums" 22 | (list Socket.DTO.AlbumSummary.decode) 23 | ) 24 | 25 | 26 | convert : Artist -> Entities.Artist.Artist 27 | convert artist = 28 | { id = ArtistId artist.id 29 | , name = artist.name 30 | , albums = List.map Socket.DTO.AlbumSummary.convert artist.albums 31 | } 32 | -------------------------------------------------------------------------------- /elm/src/Socket/DTO/ArtistSummary.elm: -------------------------------------------------------------------------------- 1 | module Socket.DTO.ArtistSummary exposing (ArtistSummary, convert, decode) 2 | 3 | import Artist.Types exposing (ArtistId(..)) 4 | import Entities.ArtistSummary 5 | import Json.Decode exposing (Decoder, field, int, map2, string) 6 | 7 | 8 | type alias ArtistSummary = 9 | { id : Int 10 | , name : String 11 | } 12 | 13 | 14 | decode : Decoder ArtistSummary 15 | decode = 16 | map2 ArtistSummary 17 | (field "id" int) 18 | (field "name" string) 19 | 20 | 21 | convert : ArtistSummary -> Entities.ArtistSummary.ArtistSummary 22 | convert album = 23 | { id = ArtistId album.id 24 | , name = album.name 25 | } 26 | -------------------------------------------------------------------------------- /elm/src/Socket/DTO/Playlist.elm: -------------------------------------------------------------------------------- 1 | module Socket.DTO.Playlist exposing (Playlist, convert, decode) 2 | 3 | import Entities.Playlist 4 | import Json.Decode exposing (Decoder, field, int, list, map3, string) 5 | import Socket.DTO.SongSummary exposing (SongSummary) 6 | import Song.Types exposing (SongId(..)) 7 | 8 | 9 | type alias Playlist = 10 | { id : Int 11 | , name : String 12 | , songs : List SongSummary 13 | } 14 | 15 | 16 | decode : Decoder Playlist 17 | decode = 18 | map3 Playlist 19 | (field "id" int) 20 | (field "name" string) 21 | (field "songs" <| list Socket.DTO.SongSummary.decode) 22 | 23 | 24 | convert : Playlist -> Entities.Playlist.Playlist 25 | convert playlist = 26 | { id = playlist.id 27 | , name = playlist.name 28 | , songs = List.map (.id >> SongId) playlist.songs 29 | } 30 | -------------------------------------------------------------------------------- /elm/src/Socket/DTO/SongSummary.elm: -------------------------------------------------------------------------------- 1 | module Socket.DTO.SongSummary exposing (SongSummary, convert, convertMany, decode) 2 | 3 | import Entities.SongSummary 4 | import Json.Decode exposing (Decoder, field, int, map3, string) 5 | import Song.Types exposing (SongId(..)) 6 | 7 | 8 | type alias SongSummary = 9 | { id : Int 10 | , name : String 11 | , track : Int 12 | } 13 | 14 | 15 | decode : Decoder SongSummary 16 | decode = 17 | map3 SongSummary 18 | (field "id" int) 19 | (field "name" string) 20 | (field "track" int) 21 | 22 | 23 | convert : SongSummary -> Entities.SongSummary.SongSummary 24 | convert song = 25 | { id = SongId song.id 26 | , name = song.name 27 | , track = song.track 28 | } 29 | 30 | 31 | convertMany : List SongSummary -> List Entities.SongSummary.SongSummary 32 | convertMany list = 33 | List.map convert list 34 | -------------------------------------------------------------------------------- /elm/src/Socket/Listeners/ScanStatus.elm: -------------------------------------------------------------------------------- 1 | module Socket.Listeners.ScanStatus exposing (listener) 2 | 3 | import Json.Decode exposing (bool, int) 4 | import Model exposing (Model) 5 | import Msg exposing (Msg) 6 | import Socket.NotificationListener exposing (NotificationListener, makeListenerWithParams) 7 | import Types exposing (Update) 8 | 9 | 10 | type alias Params = 11 | { count : Int 12 | , scanning : Bool 13 | } 14 | 15 | 16 | paramsDecoder : Json.Decode.Decoder Params 17 | paramsDecoder = 18 | Json.Decode.map2 19 | Params 20 | (Json.Decode.field "count" int) 21 | (Json.Decode.field "scanning" bool) 22 | 23 | 24 | listener : NotificationListener Model Msg 25 | listener = 26 | makeListenerWithParams paramsDecoder updater onError 27 | 28 | 29 | onError : String -> Update Model Msg 30 | onError err model = 31 | ( { model | message = "Couldn't understand scan status: " ++ err }, Cmd.none ) 32 | 33 | 34 | updater : Params -> Update Model Msg 35 | updater params model = 36 | ( { model | isScanning = params.scanning, scanCount = params.count }, Cmd.none ) 37 | -------------------------------------------------------------------------------- /elm/src/Socket/Message.elm: -------------------------------------------------------------------------------- 1 | module Socket.Message exposing (Message(..), parse) 2 | 3 | import Json.Decode 4 | exposing 5 | ( Decoder 6 | , andThen 7 | , decodeString 8 | , errorToString 9 | , fail 10 | , field 11 | , map 12 | , oneOf 13 | , string 14 | ) 15 | import Socket.Notification exposing (Notification) 16 | import Socket.Response exposing (Response) 17 | 18 | 19 | {-| A message received through a websocket. 20 | -} 21 | type Message 22 | = Response Response 23 | | Notification Notification 24 | 25 | 26 | {-| Try to parse a JSON string into a message. 27 | -} 28 | parse : String -> Result String Message 29 | parse = 30 | decodeString decode >> Result.mapError errorToString 31 | 32 | 33 | {-| Decode JSON into a message. 34 | -} 35 | decode : Decoder Message 36 | decode = 37 | field "jsonrpc" string 38 | |> andThen decodeInner 39 | 40 | 41 | decodeInner : String -> Decoder Message 42 | decodeInner version = 43 | case version of 44 | "2.0" -> 45 | oneOf 46 | [ map Response Socket.Response.decode 47 | , map Notification Socket.Notification.decode 48 | ] 49 | 50 | _ -> 51 | fail "Bad RPC version!" 52 | -------------------------------------------------------------------------------- /elm/src/Socket/MessageId.elm: -------------------------------------------------------------------------------- 1 | module Socket.MessageId exposing (MessageId(..), getRawMessageId) 2 | 3 | 4 | type MessageId 5 | = MessageId Int 6 | 7 | 8 | getRawMessageId : MessageId -> Int 9 | getRawMessageId messageId = 10 | let 11 | (MessageId id) = 12 | messageId 13 | in 14 | id 15 | -------------------------------------------------------------------------------- /elm/src/Socket/Methods/GetAlbums.elm: -------------------------------------------------------------------------------- 1 | module Socket.Methods.GetAlbums exposing (getAlbums) 2 | 3 | import Dict 4 | import Json.Decode exposing (field, list) 5 | import Model exposing (Model) 6 | import Msg exposing (Msg) 7 | import Socket.DTO.AlbumSummary exposing (AlbumSummary, convert, decode) 8 | import Socket.Listener exposing (Listener, makeIrresponsibleListener) 9 | import Socket.RequestData exposing (RequestData) 10 | import Types exposing (Update) 11 | 12 | 13 | type alias Body = 14 | { albums : List AlbumSummary } 15 | 16 | 17 | getAlbums : RequestData Model 18 | getAlbums = 19 | { method = "getAlbums" 20 | , params = Nothing 21 | , listener = Just onResponse 22 | } 23 | 24 | 25 | responseDecoder : Json.Decode.Decoder Body 26 | responseDecoder = 27 | Json.Decode.map Body 28 | (field "albums" 29 | (list decode) 30 | ) 31 | 32 | 33 | onResponse : Listener Model Msg 34 | onResponse = 35 | makeIrresponsibleListener 36 | Nothing 37 | responseDecoder 38 | setAlbums 39 | 40 | 41 | setAlbums : Body -> Update Model Msg 42 | setAlbums body model = 43 | let 44 | tuples = 45 | List.map (\a -> ( a.id, convert a )) body.albums 46 | in 47 | ( { model | albums = Dict.fromList tuples }, Cmd.none ) 48 | -------------------------------------------------------------------------------- /elm/src/Socket/Methods/GetArtists.elm: -------------------------------------------------------------------------------- 1 | module Socket.Methods.GetArtists exposing (getArtists) 2 | 3 | import Dict 4 | import Json.Decode exposing (field, list) 5 | import Model exposing (Model) 6 | import Msg exposing (Msg) 7 | import Socket.DTO.ArtistSummary exposing (convert, decode) 8 | import Socket.Listener exposing (Listener, makeIrresponsibleListener) 9 | import Socket.RequestData exposing (RequestData) 10 | import Types exposing (Update) 11 | 12 | 13 | type alias Body = 14 | { artists : List Artist } 15 | 16 | 17 | type alias Artist = 18 | { id : Int 19 | , name : String 20 | } 21 | 22 | 23 | getArtists : RequestData Model 24 | getArtists = 25 | { method = "getArtists" 26 | , params = Nothing 27 | , listener = Just onResponse 28 | } 29 | 30 | 31 | responseDecoder : Json.Decode.Decoder Body 32 | responseDecoder = 33 | Json.Decode.map Body 34 | (field "artists" 35 | (list decode) 36 | ) 37 | 38 | 39 | onResponse : Listener Model Msg 40 | onResponse = 41 | makeIrresponsibleListener 42 | Nothing 43 | responseDecoder 44 | setArtists 45 | 46 | 47 | setArtists : Body -> Update Model Msg 48 | setArtists body model = 49 | let 50 | tuples = 51 | List.map (\a -> ( a.id, convert a )) body.artists 52 | in 53 | ( { model | artists = Dict.fromList tuples }, Cmd.none ) 54 | -------------------------------------------------------------------------------- /elm/src/Socket/Methods/GetPlaylists.elm: -------------------------------------------------------------------------------- 1 | module Socket.Methods.GetPlaylists exposing (getPlaylists) 2 | 3 | import Dict 4 | import Json.Decode exposing (field, int, list, string) 5 | import Model exposing (Model) 6 | import Msg exposing (Msg) 7 | import Socket.Listener exposing (Listener, makeIrresponsibleListener) 8 | import Socket.RequestData exposing (RequestData) 9 | import Types exposing (Update) 10 | 11 | 12 | type alias Body = 13 | { playlists : List Playlist } 14 | 15 | 16 | type alias Playlist = 17 | { id : Int 18 | , name : String 19 | } 20 | 21 | 22 | getPlaylists : RequestData Model 23 | getPlaylists = 24 | { method = "getPlaylists" 25 | , params = Nothing 26 | , listener = Just onResponse 27 | } 28 | 29 | 30 | responseDecoder : Json.Decode.Decoder Body 31 | responseDecoder = 32 | Json.Decode.map Body 33 | (field "playlists" 34 | (list <| 35 | Json.Decode.map2 Playlist 36 | (field "id" int) 37 | (field "name" string) 38 | ) 39 | ) 40 | 41 | 42 | onResponse : Listener Model Msg 43 | onResponse = 44 | makeIrresponsibleListener 45 | Nothing 46 | responseDecoder 47 | setPlaylists 48 | 49 | 50 | setPlaylists : Body -> Update Model Msg 51 | setPlaylists body model = 52 | let 53 | tuples = 54 | List.map (\a -> ( a.id, a )) body.playlists 55 | in 56 | ( { model | playlists = Dict.fromList tuples }, Cmd.none ) 57 | -------------------------------------------------------------------------------- /elm/src/Socket/Methods/Handshake.elm: -------------------------------------------------------------------------------- 1 | module Socket.Methods.Handshake exposing (makeRequest, prepareRequest) 2 | 3 | import Json.Decode 4 | import Json.Encode 5 | import Model exposing (Model) 6 | import Msg exposing (Msg) 7 | import Socket.Listener exposing (Listener, makeIrresponsibleListener) 8 | import Socket.RequestData exposing (RequestData) 9 | import Types exposing (Update) 10 | 11 | 12 | type alias Body = 13 | { accepted : Bool } 14 | 15 | 16 | responseDecoder : Json.Decode.Decoder Body 17 | responseDecoder = 18 | Json.Decode.map Body 19 | (Json.Decode.field "accepted" Json.Decode.bool) 20 | 21 | 22 | makeRequest : String -> Json.Encode.Value 23 | makeRequest ticket = 24 | Json.Encode.object 25 | [ ( "ticket", Json.Encode.string ticket ) ] 26 | 27 | 28 | {-| Make a message which starts the websocket handshake. 29 | -} 30 | prepareRequest : String -> Update Model Msg -> RequestData Model 31 | prepareRequest ticket onHandshakeSuccess = 32 | { method = "handshake" 33 | , params = makeRequest ticket |> Just 34 | , listener = onResponse onHandshakeSuccess |> Just 35 | } 36 | 37 | 38 | onResponse : Update Model Msg -> Listener Model Msg 39 | onResponse onHandshakeSuccess = 40 | makeIrresponsibleListener 41 | Nothing 42 | responseDecoder 43 | (onSuccess onHandshakeSuccess) 44 | 45 | 46 | onSuccess : Update Model Msg -> Body -> Update Model Msg 47 | onSuccess onHandshakeSuccess response model = 48 | if response.accepted then 49 | onHandshakeSuccess { model | message = "Websocket handshake succeeded" } 50 | 51 | else 52 | ( { model | message = "Websocket handshake failed" }, Cmd.none ) 53 | -------------------------------------------------------------------------------- /elm/src/Socket/Methods/Start.elm: -------------------------------------------------------------------------------- 1 | module Socket.Methods.Start exposing (start) 2 | 3 | import Model exposing (Model, getSocketModel, setSocketModel) 4 | import Msg exposing (Msg) 5 | import Socket.Core exposing (sendMessage, sendQueuedMessage) 6 | import Socket.Methods.GetArtists exposing (getArtists) 7 | import Types exposing (Update, combineMany) 8 | 9 | 10 | {-| This should be run once the websocket handshake is complete. 11 | -} 12 | start : Update Model Msg 13 | start = 14 | combineMany 15 | [ setWebsocketOpen 16 | , processQueue 17 | , sendMessage getArtists False 18 | ] 19 | 20 | 21 | setWebsocketOpen : Update Model Msg 22 | setWebsocketOpen model = 23 | let 24 | socket = 25 | getSocketModel model 26 | 27 | updated = 28 | setSocketModel model { socket | isOpen = True } 29 | in 30 | ( updated, Cmd.none ) 31 | 32 | 33 | processQueue : Update Model Msg 34 | processQueue model = 35 | combineMany 36 | (List.map sendQueuedMessage (getSocketModel model).messageQueue) 37 | model 38 | -------------------------------------------------------------------------------- /elm/src/Socket/Methods/StartScan.elm: -------------------------------------------------------------------------------- 1 | module Socket.Methods.StartScan exposing (prepareRequest) 2 | 3 | import Json.Encode 4 | import Model exposing (Model) 5 | import Socket.RequestData exposing (RequestData) 6 | 7 | 8 | prepareRequest : Bool -> Bool -> RequestData Model 9 | prepareRequest shouldUpdate shouldDelete = 10 | { method = "startScan" 11 | , params = makeRequest shouldUpdate shouldDelete |> Just 12 | , listener = Nothing 13 | } 14 | 15 | 16 | makeRequest : Bool -> Bool -> Json.Encode.Value 17 | makeRequest shouldUpdate shouldDelete = 18 | Json.Encode.object 19 | [ ( "update", Json.Encode.bool shouldUpdate ) 20 | , ( "delete", Json.Encode.bool shouldDelete ) 21 | ] 22 | -------------------------------------------------------------------------------- /elm/src/Socket/Model.elm: -------------------------------------------------------------------------------- 1 | module Socket.Model exposing (Model) 2 | 3 | import Dict exposing (Dict) 4 | import Msg exposing (Msg) 5 | import Socket.Listener exposing (Listener) 6 | import Socket.MessageId exposing (MessageId) 7 | import Socket.NotificationListener exposing (NotificationListener) 8 | import Socket.RequestData exposing (RequestData) 9 | 10 | 11 | type alias Model m = 12 | { listeners : Dict Int (Listener m Msg) -- Everything listening out for a server response, keyed by the id of the response they listen for. 13 | , notificationListeners : Dict String (NotificationListener m Msg) -- Everything listening out for server notifications, keyed by the notification method they listen for. 14 | , messageQueue : List ( MessageId, RequestData m ) -- Queue for messages which have arrived while the socket is closed 15 | , nextMessageId : MessageId -- The next unused ID for a message 16 | , isOpen : Bool -- True iff the socket is open and authenticated 17 | , ticket : Maybe String 18 | } 19 | -------------------------------------------------------------------------------- /elm/src/Socket/Notification.elm: -------------------------------------------------------------------------------- 1 | module Socket.Notification exposing (Notification, decode) 2 | 3 | import Json.Decode exposing (Decoder, field, map2, maybe, string, value) 4 | 5 | 6 | {-| A websocket message which doesn't expect a reply. 7 | -} 8 | type alias Notification = 9 | { method : String 10 | , params : Maybe Json.Decode.Value 11 | } 12 | 13 | 14 | decode : Decoder Notification 15 | decode = 16 | map2 Notification 17 | (field "method" string) 18 | (maybe <| field "params" value) 19 | -------------------------------------------------------------------------------- /elm/src/Socket/NotificationListener.elm: -------------------------------------------------------------------------------- 1 | module Socket.NotificationListener exposing (NotificationListener, makeListener, makeListenerWithParams) 2 | 3 | import Json.Decode exposing (Decoder, errorToString) 4 | import Socket.Notification exposing (Notification) 5 | import Types exposing (Update) 6 | 7 | 8 | type alias NotificationListener model msg = 9 | Notification -> Update model msg 10 | 11 | 12 | {-| Make a listener which cares about the notification parameters. 13 | -} 14 | makeListenerWithParams : Decoder a -> (a -> Update model msg) -> (String -> Update model msg) -> NotificationListener model msg 15 | makeListenerWithParams decode update onError notification model = 16 | case notification.params of 17 | Just params -> 18 | case Json.Decode.decodeValue decode params of 19 | Ok body -> 20 | update body model 21 | 22 | -- We got the wrong type of parameters 23 | Err error -> 24 | onError (errorToString error) model 25 | 26 | -- We expected parameters but didn't get any 27 | Nothing -> 28 | onError "No parameters received" model 29 | 30 | 31 | {-| Make a listener which doesn't care about parameters. 32 | Since the the notification is pre-routed based on its method 33 | this means it doesn't depend on the notification at all. 34 | -} 35 | makeListener : Update model msg -> NotificationListener model msg 36 | makeListener = 37 | always 38 | -------------------------------------------------------------------------------- /elm/src/Socket/Request.elm: -------------------------------------------------------------------------------- 1 | module Socket.Request exposing (makeRequest) 2 | 3 | import Json.Encode exposing (Value, int, object, string) 4 | import Socket.MessageId exposing (MessageId, getRawMessageId) 5 | 6 | 7 | makeRequest : MessageId -> String -> Maybe Value -> Value 8 | makeRequest id method params = 9 | object 10 | [ ( "jsonrpc", string "2.0" ) 11 | , ( "method", string method ) 12 | , ( "params", maybeEncode params ) 13 | , ( "id", int <| getRawMessageId id ) 14 | ] 15 | 16 | 17 | maybeEncode : Maybe Value -> Value 18 | maybeEncode maybe = 19 | case maybe of 20 | Just value -> 21 | value 22 | 23 | Nothing -> 24 | Json.Encode.object [] 25 | -------------------------------------------------------------------------------- /elm/src/Socket/RequestData.elm: -------------------------------------------------------------------------------- 1 | module Socket.RequestData exposing (RequestData) 2 | 3 | import Json.Encode 4 | import Msg exposing (Msg) 5 | import Socket.Listener 6 | 7 | 8 | {-| Describes a message to send down the websocket and optionally how to handle a response to that message. 9 | -} 10 | type alias RequestData model = 11 | { method : String 12 | , params : Maybe Json.Encode.Value 13 | , listener : Maybe (Socket.Listener.Listener model Msg) -- How any replies to the message should be handled. 14 | } 15 | -------------------------------------------------------------------------------- /elm/src/Socket/Response.elm: -------------------------------------------------------------------------------- 1 | module Socket.Response exposing (Response, decode) 2 | 3 | import Json.Decode exposing (Decoder, Value, andThen, field, int, map, oneOf, value) 4 | import Socket.MessageId exposing (MessageId(..)) 5 | 6 | 7 | {-| A reply received through a websocket. 8 | The body represents either the success JSON or error JSON of a response. 9 | -} 10 | type alias Response = 11 | { id : MessageId 12 | , body : Result Value Value 13 | } 14 | 15 | 16 | decode : Decoder Response 17 | decode = 18 | field "id" int 19 | |> andThen decodeInner 20 | 21 | 22 | decodeInner : Int -> Decoder Response 23 | decodeInner id = 24 | let 25 | make = 26 | Response (MessageId id) 27 | in 28 | oneOf 29 | [ map (Err >> make) (field "error" value) 30 | , map (Ok >> make) (field "result" value) 31 | ] 32 | -------------------------------------------------------------------------------- /elm/src/Socket/Select.elm: -------------------------------------------------------------------------------- 1 | module Socket.Select exposing (getListener, getNotificationListener) 2 | 3 | import Dict 4 | import Model 5 | import Msg exposing (Msg) 6 | import Socket.Listener exposing (Listener) 7 | import Socket.MessageId exposing (MessageId, getRawMessageId) 8 | import Socket.Model 9 | import Socket.NotificationListener exposing (NotificationListener) 10 | 11 | 12 | type alias Model = 13 | Socket.Model.Model Model.Model 14 | 15 | 16 | {-| Try and retrieve the listener with the given ID. 17 | -} 18 | getListener : MessageId -> Model -> Maybe (Listener Model.Model Msg) 19 | getListener id model = 20 | Dict.get (getRawMessageId id) model.listeners 21 | 22 | 23 | {-| Try and retrieve the notification listener for the given method. 24 | -} 25 | getNotificationListener : String -> Model -> Maybe (NotificationListener Model.Model Msg) 26 | getNotificationListener method model = 27 | Dict.get method model.notificationListeners 28 | -------------------------------------------------------------------------------- /elm/src/Socket/SocketMsg.elm: -------------------------------------------------------------------------------- 1 | module Socket.SocketMsg exposing (SocketMsg(..)) 2 | 3 | 4 | type SocketMsg 5 | = SocketOpened -- The websocket has been successfully opened 6 | | SocketClosed -- The websocket has been closed 7 | | SocketIn String -- A message has been received over the websocket 8 | -------------------------------------------------------------------------------- /elm/src/Song/Select.elm: -------------------------------------------------------------------------------- 1 | module Song.Select exposing (getSong) 2 | 3 | import Audio.State exposing (State(..)) 4 | import Dict 5 | import Entities.SongSummary exposing (SongSummary) 6 | import Loadable exposing (Loadable(..)) 7 | import Model exposing (Model) 8 | import Routing exposing (Route(..)) 9 | import Song.Types exposing (SongId(..)) 10 | 11 | 12 | getSong : Model -> SongId -> Maybe SongSummary 13 | getSong model songId = 14 | let 15 | (SongId id) = 16 | songId 17 | in 18 | Dict.get id model.songs 19 | -------------------------------------------------------------------------------- /elm/src/Song/Types.elm: -------------------------------------------------------------------------------- 1 | module Song.Types exposing (SongId(..), getRawSongId) 2 | 3 | 4 | type SongId 5 | = SongId Int 6 | 7 | 8 | getRawSongId : SongId -> Int 9 | getRawSongId songId = 10 | let 11 | (SongId raw) = 12 | songId 13 | in 14 | raw 15 | -------------------------------------------------------------------------------- /elm/src/Types.elm: -------------------------------------------------------------------------------- 1 | module Types exposing (Update, UpdateWithReturn, combine, combineMany, noOp) 2 | 3 | 4 | type alias Update model msg = 5 | model -> ( model, Cmd msg ) 6 | 7 | 8 | type alias UpdateWithReturn model msg return = 9 | model -> ( ( model, Cmd msg ), return ) 10 | 11 | 12 | noOp : Update model msg 13 | noOp model = 14 | ( model, Cmd.none ) 15 | 16 | 17 | {-| Running one update and then another. 18 | -} 19 | combine : Update model msg -> Update model msg -> Update model msg 20 | combine first second model = 21 | let 22 | ( modelA, cmdA ) = 23 | first model 24 | 25 | ( modelB, cmdB ) = 26 | second modelA 27 | in 28 | ( modelB, Cmd.batch [ cmdB, cmdA ] ) 29 | 30 | 31 | {-| Running all the given updates in the order they appear in the list. 32 | -} 33 | combineMany : List (Update model msg) -> Update model msg 34 | combineMany updates = 35 | List.foldr combine noOp updates 36 | -------------------------------------------------------------------------------- /elm/src/Updaters.elm: -------------------------------------------------------------------------------- 1 | module Updaters exposing 2 | ( logOut 3 | , onUrlChange 4 | ) 5 | 6 | import Album.Fetch exposing (fetchAlbum) 7 | import Artist.Fetch exposing (fetchArtist) 8 | import Artist.Types exposing (ArtistId(..)) 9 | import Audio.Select exposing (..) 10 | import Audio.State exposing (State(..)) 11 | import Loadable exposing (Loadable(..)) 12 | import Model exposing (Model) 13 | import Msg exposing (Msg) 14 | import Playlist.Fetch exposing (fetchPlaylist) 15 | import Ports 16 | import Routing exposing (Route(..)) 17 | import Socket.Core exposing (sendMessage) 18 | import Socket.MessageId exposing (MessageId(..)) 19 | import Socket.Methods.GetAlbums exposing (getAlbums) 20 | import Socket.Methods.GetArtists exposing (getArtists) 21 | import Socket.Methods.GetPlaylists exposing (getPlaylists) 22 | import Types exposing (Update) 23 | import Url exposing (Url) 24 | 25 | 26 | logOut : Update Model Msg 27 | logOut model = 28 | ( { model | username = "", token = Absent }, Ports.websocketClose () ) 29 | 30 | 31 | onUrlChange : Url -> Update Model Msg 32 | onUrlChange url model = 33 | let 34 | m = 35 | { model | route = Routing.parseUrl url } 36 | in 37 | case m.route of 38 | Just (Artist id) -> 39 | fetchArtist Nothing id m 40 | 41 | Just (Album id) -> 42 | fetchAlbum Nothing id m 43 | 44 | Just (Playlist id) -> 45 | fetchPlaylist Nothing id m 46 | 47 | Just Playlists -> 48 | sendMessage getPlaylists False m 49 | 50 | Just Albums -> 51 | sendMessage getAlbums False m 52 | 53 | Just Artists -> 54 | sendMessage getArtists False m 55 | 56 | Nothing -> 57 | ( m, Cmd.none ) 58 | -------------------------------------------------------------------------------- /elm/src/Util.elm: -------------------------------------------------------------------------------- 1 | module Util exposing (insertMany) 2 | 3 | import Dict exposing (Dict) 4 | 5 | 6 | insertMany : (a -> comparable) -> (a -> value) -> List a -> Dict comparable value -> Dict comparable value 7 | insertMany toKey toValue list dict = 8 | List.foldl 9 | (\a -> \d -> Dict.insert (toKey a) (toValue a) d) 10 | dict 11 | list 12 | -------------------------------------------------------------------------------- /elm/src/Views/Album.elm: -------------------------------------------------------------------------------- 1 | module Views.Album exposing (view) 2 | 3 | import Album.Select exposing (getAlbum, getAlbumArt, getAlbumSongs) 4 | import Album.Types exposing (AlbumId) 5 | import Audio.Msg exposing (AudioMsg(..)) 6 | import Html.Styled exposing (Html, button, div, img, text) 7 | import Html.Styled.Attributes exposing (class, src) 8 | import Html.Styled.Events exposing (onClick) 9 | import Loadable exposing (Loadable(..)) 10 | import Model exposing (Model) 11 | import Msg exposing (Msg(..)) 12 | import Player.Msg exposing (PlayerMsg(..)) 13 | import Views.Song 14 | 15 | 16 | view : AlbumId -> Model -> Html Msg 17 | view id model = 18 | case getAlbum id model of 19 | Absent -> 20 | div [] [ text "No album" ] 21 | 22 | Loading _ -> 23 | div [] [ text "Loading album" ] 24 | 25 | Loaded album -> 26 | div [] 27 | [ div [] 28 | [ div [] [ text album.name ] 29 | , img [ class "album__art", src <| getAlbumArt album.artId ] [] 30 | , button [ onClick <| PlayerMsg (PlayAlbum id) ] [ text "Play album" ] 31 | ] 32 | , div [] <| 33 | List.map (Views.Song.view model) (getAlbumSongs album model) 34 | ] 35 | -------------------------------------------------------------------------------- /elm/src/Views/Albums.elm: -------------------------------------------------------------------------------- 1 | module Views.Albums exposing (view) 2 | 3 | import Album.Types exposing (getRawAlbumId) 4 | import Dict 5 | import Entities.AlbumSummary exposing (AlbumSummaries) 6 | import Html.Styled exposing (Html, a, div, text) 7 | import Html.Styled.Attributes exposing (class, href) 8 | import Model exposing (Model) 9 | import Msg exposing (Msg(..)) 10 | import String exposing (fromInt) 11 | 12 | 13 | view : Model -> Html Msg 14 | view model = 15 | div [ class "home__wrap" ] 16 | [ viewAlbums model.albums 17 | ] 18 | 19 | 20 | viewAlbums : AlbumSummaries -> Html msg 21 | viewAlbums albums = 22 | div [ class "home__artists" ] 23 | (List.map 24 | (\album -> 25 | div 26 | [ class "home__artist" ] 27 | [ a [ href <| "/album/" ++ fromInt (getRawAlbumId album.id) ] [ text album.name ] ] 28 | ) 29 | (Dict.values albums) 30 | ) 31 | -------------------------------------------------------------------------------- /elm/src/Views/Artist.elm: -------------------------------------------------------------------------------- 1 | module Views.Artist exposing (view) 2 | 3 | import Artist.Select exposing (getArtist) 4 | import Artist.Types exposing (ArtistId) 5 | import Audio.Msg exposing (AudioMsg(..)) 6 | import Html.Styled exposing (Html, div, text) 7 | import Html.Styled.Attributes exposing (class) 8 | import Loadable exposing (Loadable(..)) 9 | import Model exposing (Model) 10 | import Msg exposing (Msg(..)) 11 | import Views.MiniAlbum 12 | 13 | 14 | view : ArtistId -> Model -> Html Msg 15 | view artistId model = 16 | case getArtist artistId model of 17 | Absent -> 18 | div [] [ text "No artist" ] 19 | 20 | Loading _ -> 21 | div [] [ text "Loading artist" ] 22 | 23 | Loaded artist -> 24 | div [] 25 | [ div [] [ text artist.name ] 26 | , div [ class "artist__albums" ] <| 27 | List.map 28 | Views.MiniAlbum.view 29 | artist.albums 30 | ] 31 | -------------------------------------------------------------------------------- /elm/src/Views/Artists.elm: -------------------------------------------------------------------------------- 1 | module Views.Artists exposing (view) 2 | 3 | import Artist.Types exposing (getRawArtistId) 4 | import Dict 5 | import Entities.ArtistSummary exposing (ArtistSummaries) 6 | import Html.Styled exposing (Html, a, div, text) 7 | import Html.Styled.Attributes exposing (class, href) 8 | import Model exposing (Model) 9 | import Msg exposing (Msg(..)) 10 | import String exposing (fromInt) 11 | 12 | 13 | view : Model -> Html Msg 14 | view model = 15 | div [ class "home__wrap" ] 16 | [ viewAlbums model.artists 17 | ] 18 | 19 | 20 | viewAlbums : ArtistSummaries -> Html msg 21 | viewAlbums albums = 22 | div [ class "home__artists" ] 23 | (List.map 24 | (\album -> 25 | div 26 | [ class "home__artist" ] 27 | [ a [ href <| "/artist/" ++ fromInt (getRawArtistId album.id) ] [ text album.name ] ] 28 | ) 29 | (Dict.values albums) 30 | ) 31 | -------------------------------------------------------------------------------- /elm/src/Views/Home.elm: -------------------------------------------------------------------------------- 1 | module Views.Home exposing (view) 2 | 3 | import Html.Styled exposing (Html, button, div, input, label, span, text) 4 | import Html.Styled.Attributes exposing (checked, class, type_) 5 | import Html.Styled.Events exposing (onClick) 6 | import Model exposing (Model) 7 | import Msg exposing (Msg(..)) 8 | import String exposing (fromInt) 9 | 10 | 11 | view : Model -> Html Msg 12 | view model = 13 | div [ class "home__wrap" ] 14 | [ span [] [ text <| "Scanned: " ++ String.fromInt model.scanCount ] 15 | , button [ onClick LogOut ] [ text "Log out" ] 16 | , checkboxInput "Update?" model.scanShouldUpdate ToggleScanUpdate 17 | , checkboxInput "Delete?" model.scanShouldDelete ToggleScanDelete 18 | , button [ onClick StartScan ] [ text "Start scan" ] 19 | ] 20 | 21 | 22 | checkboxInput : String -> Bool -> msg -> Html msg 23 | checkboxInput name isChecked msg = 24 | label [] [ input [ checked isChecked, type_ "checkbox", onClick msg ] [], text name ] 25 | -------------------------------------------------------------------------------- /elm/src/Views/Login.elm: -------------------------------------------------------------------------------- 1 | module Views.Login exposing (view) 2 | 3 | import Html.Styled exposing (Html, button, div, form, input, text) 4 | import Html.Styled.Attributes exposing (class, disabled, name, placeholder, type_, value) 5 | import Html.Styled.Events exposing (onInput) 6 | import Json.Decode 7 | import Model exposing (Model) 8 | import Msg exposing (Msg(..)) 9 | 10 | 11 | view : Model -> Html Msg 12 | view model = 13 | div [ class "login__wrap" ] 14 | [ form [ class "login__container" ] 15 | [ div [ class "login__logo " ] [ text "Sound." ] 16 | , viewInput "username" "text" "Username" model.username UsernameChanged 17 | , viewInput "password" "password" "Password" model.password PasswordChanged 18 | , button [ onClickNoBubble SubmitLogin, class "login__submit", disabled (model.username == "") ] [ text "Login" ] 19 | ] 20 | ] 21 | 22 | 23 | viewInput : String -> String -> String -> String -> (String -> msg) -> Html msg 24 | viewInput n t p v toMsg = 25 | input [ name n, type_ t, placeholder p, value v, onInput toMsg, class "login__input" ] [] 26 | 27 | 28 | onClickNoBubble : msg -> Html.Styled.Attribute msg 29 | onClickNoBubble message = 30 | Html.Styled.Events.custom "click" (Json.Decode.succeed { message = message, stopPropagation = True, preventDefault = True }) 31 | -------------------------------------------------------------------------------- /elm/src/Views/MiniAlbum.elm: -------------------------------------------------------------------------------- 1 | module Views.MiniAlbum exposing (view) 2 | 3 | import Album.Select exposing (getAlbumArt) 4 | import Album.Types exposing (getRawAlbumId) 5 | import Audio.Msg exposing (AudioMsg(..)) 6 | import Entities.AlbumSummary exposing (AlbumSummary) 7 | import Html.Styled exposing (Html, a, button, div, img, text) 8 | import Html.Styled.Attributes exposing (class, href, src) 9 | import Html.Styled.Events exposing (onClick) 10 | import Loadable exposing (Loadable(..)) 11 | import Msg exposing (Msg(..)) 12 | import Player.Msg exposing (PlayerMsg(..)) 13 | import String exposing (fromInt) 14 | 15 | 16 | view : AlbumSummary -> Html Msg 17 | view album = 18 | div [ class "home__artist" ] 19 | [ div [] 20 | [ img [ class "artist__album--art", src <| getAlbumArt album.artId ] [] 21 | , a [ href <| "/album/" ++ fromInt (getRawAlbumId album.id) ] [ text album.name ] 22 | , button [ onClick <| PlayerMsg (PlayAlbum album.id) ] [ text "Play" ] 23 | ] 24 | ] 25 | -------------------------------------------------------------------------------- /elm/src/Views/Playlist.elm: -------------------------------------------------------------------------------- 1 | module Views.Playlist exposing (view) 2 | 3 | import Audio.Msg exposing (AudioMsg(..)) 4 | import Html.Styled exposing (Html, button, div, text) 5 | import Html.Styled.Events exposing (onClick) 6 | import Loadable exposing (Loadable(..)) 7 | import Model exposing (Model) 8 | import Msg exposing (Msg(..)) 9 | import Player.Msg exposing (PlayerMsg(..)) 10 | import Playlist.Select exposing (getPlaylist, getPlaylistSongs) 11 | import Playlist.Types exposing (PlaylistId) 12 | import Views.Song 13 | 14 | 15 | view : PlaylistId -> Model -> Html Msg 16 | view playlistId model = 17 | case getPlaylist playlistId model of 18 | Absent -> 19 | div [] [ text "No playlist" ] 20 | 21 | Loading _ -> 22 | div [] [ text "Loading playlist" ] 23 | 24 | Loaded playlist -> 25 | div [] 26 | [ div [] 27 | [ div [] [ text playlist.name ] 28 | , button [ onClick <| PlayerMsg (PlayPlaylist playlistId) ] [ text "Play playlist" ] 29 | ] 30 | , div [] <| 31 | List.map (Views.Song.view model) (getPlaylistSongs playlist model) 32 | ] 33 | -------------------------------------------------------------------------------- /elm/src/Views/PlaylistItem.elm: -------------------------------------------------------------------------------- 1 | module Views.PlaylistItem exposing (view) 2 | 3 | import Audio.Msg exposing (AudioMsg(..)) 4 | import Html.Styled exposing (Html, button, div, text) 5 | import Html.Styled.Attributes exposing (class) 6 | import Html.Styled.Events exposing (onClick) 7 | import Loadable exposing (Loadable(..)) 8 | import Model exposing (Model) 9 | import Msg exposing (Msg(..)) 10 | import Player.Msg exposing (PlayerMsg(..)) 11 | import Song.Select exposing (getSong) 12 | import Song.Types exposing (SongId) 13 | 14 | 15 | view : Model -> Int -> SongId -> Html Msg 16 | view model index songId = 17 | case getSong model songId of 18 | Just song -> 19 | div [ class "playlist__item" ] 20 | [ button [ onClick <| PlayerMsg (PlayItem index) ] [ text "Play" ] 21 | , div [] [ text song.name ] 22 | ] 23 | 24 | Nothing -> 25 | div [] [ text "Nothing" ] 26 | -------------------------------------------------------------------------------- /elm/src/Views/Playlists.elm: -------------------------------------------------------------------------------- 1 | module Views.Playlists exposing (view) 2 | 3 | import Dict 4 | import Entities.PlaylistSummary exposing (PlaylistSummaries) 5 | import Html.Styled exposing (Html, a, div, text) 6 | import Html.Styled.Attributes exposing (class, href) 7 | import Model exposing (Model) 8 | import Msg exposing (Msg(..)) 9 | import String exposing (fromInt) 10 | 11 | 12 | view : Model -> Html Msg 13 | view model = 14 | div [ class "home__wrap" ] 15 | [ viewPlaylists model.playlists 16 | ] 17 | 18 | 19 | viewPlaylists : PlaylistSummaries -> Html msg 20 | viewPlaylists playlists = 21 | div [ class "home__artists" ] 22 | (List.map 23 | (\playlist -> div [ class "home__artist" ] [ a [ href <| "/playlist/" ++ fromInt playlist.id ] [ text playlist.name ] ]) 24 | (Dict.values playlists) 25 | ) 26 | -------------------------------------------------------------------------------- /elm/src/Views/Sidebar.elm: -------------------------------------------------------------------------------- 1 | module Views.Sidebar exposing (view) 2 | 3 | import Html.Styled exposing (Html, a, div, text) 4 | import Html.Styled.Attributes exposing (class, href) 5 | import Model exposing (Model) 6 | import Msg exposing (Msg(..)) 7 | 8 | 9 | view : Model -> Html Msg 10 | view model = 11 | div 12 | [] 13 | [ div 14 | [ class "app__header" ] 15 | [ a [ href "/" ] [ text "Home" ] ] 16 | , div 17 | [ class "app__header" ] 18 | [ a [ href "/artists" ] [ text "Artists" ] ] 19 | , div 20 | [ class "app__header" ] 21 | [ a [ href "/albums" ] [ text "Albums" ] ] 22 | , div 23 | [ class "app__header" ] 24 | [ a [ href "/playlists" ] [ text "Playlists" ] ] 25 | ] 26 | -------------------------------------------------------------------------------- /elm/src/Views/Song.elm: -------------------------------------------------------------------------------- 1 | module Views.Song exposing (view) 2 | 3 | import Audio.Msg exposing (AudioMsg(..)) 4 | import Entities.SongSummary exposing (SongSummary) 5 | import Html.Styled exposing (Html, button, div, text) 6 | import Html.Styled.Attributes exposing (class) 7 | import Html.Styled.Events exposing (onClick) 8 | import Loadable exposing (Loadable(..)) 9 | import Model exposing (Model) 10 | import Msg exposing (Msg(..)) 11 | import Player.Msg exposing (PlayerMsg(..)) 12 | import String exposing (fromInt) 13 | 14 | 15 | view : Model -> Maybe SongSummary -> Html Msg 16 | view model maybeSong = 17 | case maybeSong of 18 | Just song -> 19 | div [ class "album__song" ] 20 | [ button [ onClick <| PlayerMsg (Play song.id) ] [ text "Play" ] 21 | , button [ onClick <| PlayerMsg (Queue song.id) ] [ text "Queue" ] 22 | , div [] [ text <| fromInt song.track ] 23 | , div [] [ text song.name ] 24 | ] 25 | 26 | Nothing -> 27 | div [ class "album__song" ] [] 28 | -------------------------------------------------------------------------------- /elm/src/css/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, 7 | body, 8 | div, 9 | span, 10 | applet, 11 | object, 12 | iframe, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | blockquote, 21 | pre, 22 | a, 23 | abbr, 24 | acronym, 25 | address, 26 | big, 27 | cite, 28 | code, 29 | del, 30 | dfn, 31 | em, 32 | img, 33 | ins, 34 | kbd, 35 | q, 36 | s, 37 | samp, 38 | small, 39 | strike, 40 | strong, 41 | sub, 42 | sup, 43 | tt, 44 | var, 45 | b, 46 | u, 47 | i, 48 | center, 49 | dl, 50 | dt, 51 | dd, 52 | ol, 53 | ul, 54 | li, 55 | fieldset, 56 | form, 57 | label, 58 | legend, 59 | table, 60 | caption, 61 | tbody, 62 | tfoot, 63 | thead, 64 | tr, 65 | th, 66 | td, 67 | article, 68 | aside, 69 | canvas, 70 | details, 71 | embed, 72 | figure, 73 | figcaption, 74 | footer, 75 | header, 76 | hgroup, 77 | menu, 78 | nav, 79 | output, 80 | ruby, 81 | section, 82 | summary, 83 | time, 84 | mark, 85 | audio, 86 | video { 87 | margin: 0; 88 | padding: 0; 89 | border: 0; 90 | font-size: 100%; 91 | font: inherit; 92 | vertical-align: baseline; 93 | } 94 | /* HTML5 display-role reset for older browsers */ 95 | article, 96 | aside, 97 | details, 98 | figcaption, 99 | figure, 100 | footer, 101 | header, 102 | hgroup, 103 | menu, 104 | nav, 105 | section { 106 | display: block; 107 | } 108 | body { 109 | line-height: 1; 110 | } 111 | ol, 112 | ul { 113 | list-style: none; 114 | } 115 | blockquote, 116 | q { 117 | quotes: none; 118 | } 119 | blockquote:before, 120 | blockquote:after, 121 | q:before, 122 | q:after { 123 | content: ""; 124 | content: none; 125 | } 126 | table { 127 | border-collapse: collapse; 128 | border-spacing: 0; 129 | } 130 | -------------------------------------------------------------------------------- /elm/src/sass/_album.scss: -------------------------------------------------------------------------------- 1 | .album { 2 | &__song { 3 | display: flex; 4 | } 5 | 6 | &__art { 7 | width: 8rem; 8 | height: 8rem; 9 | object-fit: contain; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /elm/src/sass/_app.scss: -------------------------------------------------------------------------------- 1 | .app { 2 | &__wrap { 3 | display: grid; 4 | grid-template-areas: 5 | "header header" 6 | "side main" 7 | "playlist playlist" 8 | "player player"; 9 | grid-template-rows: auto 1fr auto auto; 10 | grid-template-columns: auto 1fr; 11 | 12 | overflow: hidden; 13 | } 14 | 15 | &__header { 16 | grid-area: header; 17 | } 18 | 19 | &__main { 20 | grid-area: main; 21 | overflow-y: auto; 22 | } 23 | 24 | &__side { 25 | grid-area: side; 26 | } 27 | 28 | &__playlist { 29 | grid-area: playlist; 30 | max-height: 30vh; 31 | overflow: auto; 32 | background-color: black; 33 | color: white; 34 | } 35 | 36 | &__player { 37 | grid-area: player; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /elm/src/sass/_home.scss: -------------------------------------------------------------------------------- 1 | .home { 2 | &__wrap { 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | &__artist { 8 | display: inline-block; 9 | padding: 0.1rem 0.2rem; 10 | text-decoration: underline; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /elm/src/sass/_login.scss: -------------------------------------------------------------------------------- 1 | .login { 2 | &__wrap { 3 | width: 100vw; 4 | height: 100vh; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | box-sizing: border-box; 9 | } 10 | 11 | &__container { 12 | width: 20rem; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | box-sizing: border-box; 17 | flex-direction: column; 18 | } 19 | 20 | &__input { 21 | padding: 1rem; 22 | font-size: 1rem; 23 | width: 100%; 24 | margin: 0.5rem; 25 | border: 1px #808080 solid; 26 | border-radius: 0.3rem; 27 | } 28 | 29 | &__submit { 30 | padding: 1.5rem; 31 | font-size: 0.9rem; 32 | width: 100%; 33 | margin-top: 1rem; 34 | border: 1px #808080 solid; 35 | border-radius: 0.3rem; 36 | color: white; 37 | background-color: #2e2e2e; 38 | } 39 | 40 | &__logo { 41 | font-family: "Shrikhand", cursive; 42 | font-size: 4rem; 43 | color: #2e2e2e; 44 | padding-bottom: 1rem; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /elm/src/sass/_player.scss: -------------------------------------------------------------------------------- 1 | .player { 2 | &__wrap { 3 | display: flex; 4 | flex-direction: column; 5 | 6 | background-color: black; 7 | color: white; 8 | 9 | padding: 1rem; 10 | } 11 | 12 | &__slider { 13 | &--wrap { 14 | width: 100%; 15 | background-color: blue; 16 | height: 1rem; 17 | 18 | position: relative; 19 | } 20 | 21 | &--elapsed { 22 | position: absolute; 23 | top: 0; 24 | bottom: 0; 25 | left: 0; 26 | 27 | background-color: red; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /elm/src/sass/_playlist.scss: -------------------------------------------------------------------------------- 1 | .playlist { 2 | &__items { 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | &__item { 8 | display: flex; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /elm/src/sass/artist.scss: -------------------------------------------------------------------------------- 1 | .artist { 2 | &__albums { 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | &__album { 8 | &--art { 9 | width: 3rem; 10 | height: 3rem; 11 | object-fit: contain; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /elm/src/sass/styles.scss: -------------------------------------------------------------------------------- 1 | // Google Fonts 2 | @import url(https://fonts.googleapis.com/css?family=Roboto); 3 | @import url(https://fonts.googleapis.com/css?family=Shrikhand); 4 | 5 | @import "app"; 6 | @import "login"; 7 | @import "home"; 8 | @import "playlist"; 9 | @import "album"; 10 | @import "artist"; 11 | @import "player"; 12 | 13 | html * { 14 | box-sizing: border-box !important; 15 | font-family: "Roboto", sans-serif; 16 | } 17 | -------------------------------------------------------------------------------- /elm/webpack.common.js: -------------------------------------------------------------------------------- 1 | module.exports = optimize => ({ 2 | entry: "./index.js", 3 | 4 | output: { 5 | path: __dirname + "/dist", 6 | filename: "index.js" 7 | }, 8 | 9 | resolve: { extensions: [".elm", ".js", ".scss", ".css"] }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.html$/, 14 | exclude: /node_modules/, 15 | use: { 16 | loader: "file-loader", 17 | options: { 18 | name: "[name].[ext]" 19 | } 20 | } 21 | }, 22 | { 23 | test: /\.elm$/, 24 | exclude: [/elm-stuff/, /node_modules/], 25 | use: { 26 | loader: "elm-webpack-loader", 27 | options: { 28 | cwd: __dirname, 29 | cache: false, 30 | optimize 31 | } 32 | } 33 | }, 34 | { 35 | test: /\.scss$/, 36 | use: [ 37 | "style-loader", // creates style nodes from JS strings 38 | "css-loader", // translates CSS into CommonJS 39 | "sass-loader" // compiles Sass to CSS, using Node Sass by default 40 | ] 41 | }, 42 | { 43 | test: /\.css$/, 44 | use: [ 45 | "style-loader", // creates style nodes from JS strings 46 | "css-loader" // translates CSS into CommonJS 47 | ] 48 | } 49 | ], 50 | noParse: /\.elm$/ 51 | } 52 | }); 53 | -------------------------------------------------------------------------------- /elm/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const common = require("./webpack.common"); 2 | const merge = require("webpack-merge"); 3 | const path = require("path"); 4 | 5 | module.exports = merge(common(false), { 6 | mode: "development", 7 | devtool: "inline-source-map", 8 | devServer: { 9 | contentBase: path.join(__dirname, "dist"), 10 | port: 9000, 11 | historyApiFallback: true, 12 | proxy: { 13 | "/api": { 14 | target: "http://localhost:3684" 15 | }, 16 | "/ws": { 17 | target: "http://localhost:3684", 18 | ws: true 19 | } 20 | } 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /elm/webpack.home.js: -------------------------------------------------------------------------------- 1 | const common = require("./webpack.common"); 2 | const merge = require("webpack-merge"); 3 | const path = require("path"); 4 | 5 | module.exports = merge(common(false), { 6 | mode: "development", 7 | devtool: "inline-source-map", 8 | devServer: { 9 | contentBase: path.join(__dirname, "dist"), 10 | port: 9000, 11 | historyApiFallback: true, 12 | proxy: { 13 | "/api": { 14 | target: "http://192.168.1.77:7071" // "http://localhost:3684" 15 | }, 16 | "/ws": { 17 | target: "http://192.168.1.77:7071", // "http://localhost:3684" 18 | ws: true 19 | } 20 | } 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /elm/webpack.live.js: -------------------------------------------------------------------------------- 1 | const common = require("./webpack.common"); 2 | const merge = require("webpack-merge"); 3 | const path = require("path"); 4 | 5 | module.exports = merge(common(false), { 6 | mode: "development", 7 | devtool: "inline-source-map", 8 | devServer: { 9 | contentBase: path.join(__dirname, "dist"), 10 | port: 9000, 11 | historyApiFallback: true, 12 | proxy: { 13 | "/api": { 14 | target: "http://hednowley.synology.me:171" 15 | }, 16 | "/ws": { 17 | target: "http://hednowley.synology.me:171", 18 | ws: true 19 | } 20 | } 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /elm/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const common = require("./webpack.common"); 2 | const merge = require("webpack-merge"); 3 | 4 | module.exports = merge(common(true), { 5 | mode: "production" 6 | }); 7 | -------------------------------------------------------------------------------- /go/.gitignore: -------------------------------------------------------------------------------- 1 | /sound.exe 2 | /sound 3 | /debug 4 | /dto/*.test 5 | /handler/debug.test 6 | /dal/*.test 7 | *.test 8 | *.log 9 | __debug_bin 10 | -------------------------------------------------------------------------------- /go/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceRoot}", 13 | "env": {}, 14 | "args": [] 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /go/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.docsTool": "gogetdoc", 3 | "go.formatFlags": [] 4 | } 5 | -------------------------------------------------------------------------------- /go/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Run all tests", 8 | "type": "shell", 9 | "command": "go test -p 1 ./...", 10 | "problemMatcher": "$go" 11 | }, 12 | { 13 | "label": "Format all go", 14 | "type": "shell", 15 | "command": " gofmt -s -w ./" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /go/api/api/api.go: -------------------------------------------------------------------------------- 1 | // Package api is stuff. 2 | package api 3 | 4 | import ( 5 | "net/http" 6 | 7 | "github.com/hednowley/sound/config" 8 | ) 9 | 10 | type ControllerContext struct { 11 | // Pointer to a DTO struct. This struct is kept in a closure along 12 | // with the Run func to make Run like a generic function. 13 | // The struct must be mutated rather than reassigning the pointer. 14 | Body interface{} 15 | 16 | // Run the controller action. 17 | Run func(user *config.User, w http.ResponseWriter, r *http.Request) *Response 18 | } 19 | 20 | // Controller is a web controller. 21 | // It accepts a data-transfer object and returns an unserialised Response. 22 | type Controller struct { 23 | 24 | // Request token will be authenticated iff this is true 25 | Secure bool 26 | 27 | // Create a new context for an incoming request. 28 | Make func() *ControllerContext 29 | } 30 | 31 | // BinaryController is a low-level web controller. 32 | // It accepts a data-transfer object, a ResponseWriter and a Request. 33 | // It returns a nil pointer to indicate that no further response is needed, 34 | // otherwise it returns an unserialised Response. 35 | type BinaryController struct { 36 | Secure bool // Request token will be authenticated iff this is true 37 | Run func(http.ResponseWriter, *http.Request, *config.User) *Response // Run the controller action. 38 | } 39 | -------------------------------------------------------------------------------- /go/api/api/response.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type Status int 4 | 5 | const ( 6 | Success Status = 0 7 | Fail Status = 1 8 | Error Status = 2 9 | ) 10 | 11 | // Response follows JSend syntax. 12 | type Response struct { 13 | Body interface{} 14 | Status Status 15 | } 16 | 17 | func NewErrorReponse(message string) *Response { 18 | return &Response{ 19 | Body: message, 20 | Status: Error, 21 | } 22 | } 23 | 24 | func NewSuccessfulReponse(body interface{}) *Response { 25 | return &Response{ 26 | Body: body, 27 | Status: Success, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /go/api/api/spa.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // ServeSinglePageApp serves static files if they exist, otherwise it serves a single HTML page. 10 | func ServeSinglePageApp(dir string, html string) func(w http.ResponseWriter, r *http.Request) { 11 | return func(w http.ResponseWriter, r *http.Request) { 12 | 13 | path, err := filepath.Abs(r.URL.Path) 14 | if err != nil { 15 | http.Error(w, err.Error(), http.StatusBadRequest) 16 | return 17 | } 18 | 19 | _, err = os.Stat(filepath.Join(dir, path)) 20 | if os.IsNotExist(err) { 21 | // Serve the index file 22 | http.ServeFile(w, r, html) 23 | return 24 | } else if err != nil { 25 | // Some other error 26 | http.Error(w, err.Error(), http.StatusInternalServerError) 27 | return 28 | } 29 | 30 | // Requested file must exist so serve it 31 | http.FileServer(http.Dir(dir)).ServeHTTP(w, r) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /go/api/controller/art.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/hednowley/sound/api/api" 7 | "github.com/hednowley/sound/config" 8 | "github.com/hednowley/sound/dal" 9 | "github.com/hednowley/sound/util" 10 | ) 11 | 12 | // NewArtController creates a controller for serving artwork images. 13 | func NewArtController(dal *dal.DAL) *api.BinaryController { 14 | 15 | run := func(w http.ResponseWriter, r *http.Request, _ *config.User) *api.Response { 16 | 17 | params := r.URL.Query() 18 | id := params.Get("id") 19 | 20 | sizeParam := params.Get("size") 21 | size := util.ParseUint(sizeParam, 0) 22 | 23 | path := dal.GetArt(id, size) 24 | if path == nil { 25 | return api.NewErrorReponse("Art is not available") 26 | } 27 | 28 | http.ServeFile(w, r, *path) 29 | return nil 30 | } 31 | 32 | return &api.BinaryController{ 33 | Run: run, 34 | Secure: true, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /go/api/controller/authenticate.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/hednowley/sound/api/api" 7 | "github.com/hednowley/sound/api/dto" 8 | "github.com/hednowley/sound/config" 9 | "github.com/hednowley/sound/services" 10 | ) 11 | 12 | // NewAuthenticateController makes a controller which gives out JWT tokens in return for credentials. 13 | func NewAuthenticateController(authenticator *services.Authenticator) *api.Controller { 14 | 15 | make := func() *api.ControllerContext { 16 | 17 | credentials := &dto.Credentials{} 18 | 19 | run := func(_ *config.User, w http.ResponseWriter, r *http.Request) *api.Response { 20 | 21 | if authenticator.AuthenticateFromPassword(credentials.Username, credentials.Password) == nil { 22 | return api.NewErrorReponse("Bad credentials.") 23 | } 24 | 25 | token, err := authenticator.MakeJWT(credentials.Username) 26 | if err != nil { 27 | return api.NewErrorReponse("Could not make token.") 28 | } 29 | 30 | //expire := time.Now().AddDate(0, 0, 1) 31 | cookie := http.Cookie{ 32 | Name: "token", 33 | Value: token, 34 | //Domain: "false", 35 | //Expires: expire, 36 | } 37 | http.SetCookie(w, &cookie) 38 | 39 | return api.NewSuccessfulReponse(&struct{}{}) 40 | } 41 | 42 | return &api.ControllerContext{ 43 | Body: credentials, 44 | Run: run, 45 | } 46 | } 47 | 48 | return &api.Controller{ 49 | Make: make, 50 | Secure: false, 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /go/api/controller/stream.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "net/http" 5 | "path" 6 | "strings" 7 | 8 | "github.com/hednowley/sound/api/api" 9 | "github.com/hednowley/sound/config" 10 | "github.com/hednowley/sound/dal" 11 | "github.com/hednowley/sound/util" 12 | ) 13 | 14 | // NewStreamController creates a controller for streaming audio. 15 | func NewStreamController(dal *dal.DAL) *api.BinaryController { 16 | 17 | run := func(w http.ResponseWriter, r *http.Request, _ *config.User) *api.Response { 18 | 19 | params := r.URL.Query() 20 | idStr := params.Get("id") 21 | id := util.ParseUint(idStr, 0) 22 | 23 | if id == 0 { 24 | return api.NewErrorReponse("No ID!") 25 | } 26 | 27 | conn, err := dal.Db.GetConn() 28 | if err != nil { 29 | return api.NewErrorReponse(err.Error()) 30 | } 31 | defer conn.Release() 32 | 33 | file, err := dal.Db.GetSong(conn, id) 34 | if err != nil { 35 | return api.NewErrorReponse(err.Error()) 36 | } 37 | 38 | // http.ServeFile incorrectly guesses the Content-Type as "video/mp4" for AAC files 39 | // so we override it here. 40 | ext := strings.ToLower(path.Ext(file.Path)) 41 | if ext == ".aac" || ext == ".m4a" { 42 | w.Header()["Content-Type"] = []string{"audio/aac"} 43 | } 44 | 45 | http.ServeFile(w, r, file.Path) 46 | return nil 47 | } 48 | 49 | return &api.BinaryController{ 50 | Run: run, 51 | Secure: true, 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /go/api/controller/ticket.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/hednowley/sound/api/api" 7 | "github.com/hednowley/sound/api/dto" 8 | "github.com/hednowley/sound/config" 9 | "github.com/hednowley/sound/socket" 10 | ) 11 | 12 | // NewTicketController makes a controller which returns a new Websocket ticket. 13 | func NewTicketController(ticketer *socket.Ticketer) *api.Controller { 14 | 15 | make := func() *api.ControllerContext { 16 | 17 | return &api.ControllerContext{ 18 | Body: nil, 19 | Run: func(user *config.User, _ http.ResponseWriter, _ *http.Request) *api.Response { 20 | r := dto.NewTicket(ticketer.MakeTicket(user)) 21 | return api.NewSuccessfulReponse(&r) 22 | }, 23 | } 24 | } 25 | 26 | return &api.Controller{ 27 | Secure: true, 28 | Make: make, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /go/api/dto/credentials.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | // Credentials are data from a login form. 4 | type Credentials struct { 5 | Username string `json:"username"` 6 | Password string `json:"password"` 7 | } 8 | -------------------------------------------------------------------------------- /go/api/dto/ticket.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | // Ticket is a ticket which allows a new websocket session to be negotiated. 4 | type Ticket struct { 5 | Ticket string `json:"ticket"` 6 | } 7 | 8 | // NewTicket makes a new ticket. 9 | func NewTicket(ticket string) Ticket { 10 | return Ticket{ 11 | Ticket: ticket, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /go/api/dto/token.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | // Token is a JSON Web Token. 4 | type Token struct { 5 | Token string `json:"token"` 6 | } 7 | -------------------------------------------------------------------------------- /go/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "io/ioutil" 5 | 6 | "gopkg.in/yaml.v2" 7 | ) 8 | 9 | // Config is a data object which holds the app's settings. 10 | type Config struct { 11 | Port int 12 | ArtPath string `yaml:"art path"` 13 | ArtSizes []uint `yaml:"art sizes"` 14 | ResizeArt bool `yaml:"resize art"` 15 | Db string 16 | MigrationsPath string `yaml:"migrations path"` 17 | LogConfig string `yaml:"log config"` 18 | IgnoredArticles []string `yaml:"ignored articles"` 19 | Users []User 20 | Secret string 21 | AccessControlAllowOrigin string `yaml:"access control allow origin"` 22 | WebsocketTicketExpiry int `yaml:"websocket ticket expiry"` 23 | BeetsProviders []BeetsProvider `yaml:"beets"` 24 | FileSystemProviders []FileSystemProvider `yaml:"filesystem"` 25 | } 26 | 27 | type BeetsProvider struct { 28 | Database string 29 | Name string 30 | } 31 | 32 | type FileSystemProvider struct { 33 | Path string 34 | Name string 35 | Extensions []string 36 | } 37 | 38 | type User struct { 39 | Username string 40 | Password string 41 | Email string 42 | } 43 | 44 | // NewConfig creates config from the YAML at the provided path. 45 | // It's defined as a nullary function so we can inject the path before passing 46 | // the constructor to FX. 47 | func NewConfig(path string) func() (*Config, error) { 48 | return func() (*Config, error) { 49 | yamlData, err := ioutil.ReadFile(path) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | var c Config 55 | err = yaml.Unmarshal(yamlData, &c) 56 | return &c, err 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /go/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "path/filepath" 5 | "runtime" 6 | "testing" 7 | 8 | "github.com/hednowley/sound/config" 9 | ) 10 | 11 | func TestConfig(t *testing.T) { 12 | // Get test data path (found relative to this file) 13 | _, filename, _, _ := runtime.Caller(0) 14 | dir := filepath.Dir(filename) 15 | dataPath := filepath.Join(dir, "..", "testdata", "config.yaml") 16 | 17 | c, err := config.NewConfig(dataPath)() 18 | 19 | if err != nil { 20 | t.Error() 21 | } 22 | 23 | if len(c.ArtSizes) != 2 { 24 | t.Error() 25 | } 26 | 27 | if c.Port != 3684 || 28 | c.Secret != "changeme" || 29 | c.ArtPath != "~/temp/art" || 30 | c.Db != "host=localhost port=5432 user=postgres password=sound dbname=sound sslmode=disable" || 31 | c.LogConfig != "log-config.xml" || 32 | c.AccessControlAllowOrigin != "*" || 33 | c.WebsocketTicketExpiry != 30 { 34 | t.Error() 35 | } 36 | 37 | if len(c.FileSystemProviders) != 2 { 38 | t.Error() 39 | } 40 | 41 | if c.FileSystemProviders[0].Path != "~/temp/my music" || 42 | c.FileSystemProviders[0].Name != "Gertrude's bangers" || 43 | len(c.FileSystemProviders[0].Extensions) != 2 || 44 | c.FileSystemProviders[0].Extensions[0] != "mp3" || 45 | c.FileSystemProviders[0].Extensions[1] != "flac" { 46 | t.Error() 47 | } 48 | 49 | if len(c.BeetsProviders) != 1 || 50 | c.BeetsProviders[0].Database != "~/beets/beetslib.blb" || 51 | c.BeetsProviders[0].Name != "My beets music" { 52 | t.Error() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /go/dal/mock.go: -------------------------------------------------------------------------------- 1 | package dal 2 | 3 | import ( 4 | "github.com/hednowley/sound/database" 5 | ) 6 | 7 | func NewMock() *DAL { 8 | return &DAL{ 9 | Db: database.NewMock(), 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /go/dao/album.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Album is an album. 8 | type Album struct { 9 | ID uint 10 | ArtistID uint 11 | Name string 12 | 13 | Created *time.Time 14 | Arts []string 15 | Genres []string 16 | Years []int 17 | Duration int 18 | Disambiguator string // Two albums are only considered the same if their Name, Artist and Disambiguator are the same. 19 | Starred bool 20 | 21 | SongCount uint 22 | ArtistName string 23 | } 24 | 25 | func (a *Album) GetArt() string { 26 | if len(a.Arts) > 0 { 27 | return a.Arts[0] 28 | } 29 | 30 | return "" 31 | } 32 | 33 | func (a *Album) GetGenre() string { 34 | if len(a.Genres) > 0 { 35 | return a.Genres[0] 36 | } 37 | 38 | return "" 39 | } 40 | 41 | func (a *Album) GetYear() int { 42 | if len(a.Years) > 0 { 43 | return a.Years[0] 44 | } 45 | 46 | return 0 47 | } 48 | -------------------------------------------------------------------------------- /go/dao/albumList2Type.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | // AlbumList2Type is all they ways the results of AlbumList2 can be sorted: http://www.subsonic.org/pages/api.jsp#getAlbumList2 4 | type AlbumList2Type int 5 | 6 | // One of the ways the results of AlbumList2 can be sorted. 7 | const ( 8 | Random AlbumList2Type = 0 9 | Newest AlbumList2Type = 1 10 | Frequent AlbumList2Type = 2 11 | Recent AlbumList2Type = 3 12 | Starred AlbumList2Type = 4 13 | AlphabeticalByName AlbumList2Type = 5 14 | AlphabeticalByArtist AlbumList2Type = 6 15 | ByYear AlbumList2Type = 7 16 | ByGenre AlbumList2Type = 8 17 | ) 18 | -------------------------------------------------------------------------------- /go/dao/art.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | // Art is a single image of album art. 4 | type Art struct { 5 | ID uint 6 | Hash string 7 | Path string 8 | } 9 | -------------------------------------------------------------------------------- /go/dao/artist.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | // Artist is an artist. 4 | type Artist struct { 5 | ID uint 6 | Name string 7 | Arts []string 8 | Starred bool 9 | 10 | Duration int 11 | AlbumCount uint 12 | } 13 | 14 | func (a *Artist) GetArt() string { 15 | if len(a.Arts) > 0 { 16 | return a.Arts[0] 17 | } 18 | 19 | return "" 20 | } 21 | -------------------------------------------------------------------------------- /go/dao/errNotFound.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | // ErrNotFound is a special type of error for when data is missing. 4 | type ErrNotFound struct{} 5 | 6 | func (e *ErrNotFound) Error() string { 7 | return "Not found" 8 | } 9 | 10 | // IsErrNotFound check is an error is an ErrNotFound. 11 | func IsErrNotFound(e error) bool { 12 | _, ok := e.(*ErrNotFound) 13 | return ok 14 | } 15 | -------------------------------------------------------------------------------- /go/dao/genre.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | // Genre is a genre. 4 | type Genre struct { 5 | ID uint 6 | Name string 7 | SongCount int 8 | AlbumCount int 9 | } 10 | -------------------------------------------------------------------------------- /go/dao/playlist.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Playlist is a playlist. 8 | type Playlist struct { 9 | ID uint 10 | Name string 11 | Comment string 12 | Public bool 13 | Created *time.Time 14 | Changed *time.Time 15 | Duration int 16 | Owner string 17 | 18 | EntryCount int 19 | } 20 | -------------------------------------------------------------------------------- /go/dao/playlistEntry.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | // PlaylistEntry is a single instance of a song inside a playlist. 4 | type PlaylistEntry struct { 5 | ID uint 6 | PlaylistID uint 7 | SongID uint 8 | Index int 9 | } 10 | -------------------------------------------------------------------------------- /go/dao/song.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import "time" 4 | 5 | // Song is a song. 6 | type Song struct { 7 | ID uint 8 | Artist string // Name of the artist of the song. Can differ from the album's artist. 9 | AlbumID uint 10 | Path string 11 | Title string 12 | Track int 13 | Disc int 14 | Year int 15 | Art string 16 | Created *time.Time 17 | Size int64 // File size in bytes 18 | Bitrate int // Bitrate in kb/s 19 | Duration int // Duration in seconds 20 | Token string // An ID unique to this song amongst other songs from its provider 21 | ProviderID string // THe ID of the provider which supplied this song 22 | Starred bool 23 | 24 | // Precalculated fields which are stored for performance 25 | AlbumName string 26 | AlbumArtistID uint 27 | GenreName string 28 | } 29 | -------------------------------------------------------------------------------- /go/database/mock.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | "path/filepath" 7 | 8 | "github.com/go-testfixtures/testfixtures/v3" 9 | "github.com/hednowley/sound/config" 10 | "github.com/hednowley/sound/projectpath" 11 | ) 12 | 13 | // NewMock makes a new database seeded from test data. 14 | // Note that a real database must exist at the connection string below. 15 | func NewMock() *Default { 16 | 17 | conn := "host=localhost port=5432 user=sound password=sound dbname=sound_test sslmode=disable" 18 | mig := filepath.Join(projectpath.Root, "migrations") 19 | 20 | database, err := NewDefault(&config.Config{Db: conn, MigrationsPath: mig}) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | // Open the database 26 | db, err := sql.Open("postgres", conn) 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | 31 | dataDir := filepath.Join(projectpath.Root, "testdata", "dao") 32 | 33 | // Insert test data 34 | fixtures, err := testfixtures.New( 35 | testfixtures.Database(db), 36 | testfixtures.Dialect("postgres"), 37 | testfixtures.Directory(dataDir), 38 | testfixtures.ResetSequencesTo(10000), 39 | ) 40 | if err != nil { 41 | log.Fatal(err) 42 | } 43 | 44 | err = fixtures.Load() 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | 49 | return database 50 | } 51 | -------------------------------------------------------------------------------- /go/entities/fileData.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | type FileInfo struct { 4 | Path string 5 | Artist string 6 | Album string 7 | AlbumArtist string 8 | Title string 9 | Genre string 10 | Track int 11 | Disc int 12 | Year int 13 | CoverArt *CoverArtData 14 | Size int64 15 | Bitrate int // Bitrate in kb/s 16 | Duration int // Duration in seconds 17 | Disambiguator string 18 | } 19 | 20 | type CoverArtData struct { 21 | Extension string 22 | Raw []byte 23 | } 24 | -------------------------------------------------------------------------------- /go/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hednowley/sound 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 7 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 8 | github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27 9 | github.com/go-testfixtures/testfixtures/v3 v3.1.2 10 | github.com/golang-migrate/migrate/v4 v4.11.0 11 | github.com/google/uuid v1.1.1 12 | github.com/gorilla/mux v1.7.4 13 | github.com/gorilla/websocket v1.4.2 14 | github.com/jackc/pgx/v4 v4.6.0 15 | github.com/lib/pq v1.5.2 16 | github.com/mattn/go-sqlite3 v2.0.3+incompatible 17 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 18 | go.uber.org/fx v1.12.0 19 | go.uber.org/multierr v1.5.0 // indirect 20 | golang.org/x/lint v0.0.0-20200302205851-738671d3881b // indirect 21 | golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5 // indirect 22 | gopkg.in/yaml.v2 v2.2.8 23 | honnef.co/go/tools v0.0.1-2020.1.3 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /go/hasher/hasher.go: -------------------------------------------------------------------------------- 1 | package hasher 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | ) 7 | 8 | // GetHash calculates an MD5 hash. 9 | func GetHash(data []byte) string { 10 | hash := md5.New() 11 | hash.Write(data) 12 | return hex.EncodeToString(hash.Sum(nil)) 13 | } 14 | -------------------------------------------------------------------------------- /go/linux_dsm_build.sh: -------------------------------------------------------------------------------- 1 | docker run -v "$PWD":/go/src/github.com/hednowley/sound -w /go/src/github.com/hednowley/sound golang:1.14-stretch /bin/bash -c "apt-get update; apt-get install build-essential gcc-6-arm-linux-gnueabihf -y; go get -d -v; GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=1 CC=arm-linux-gnueabihf-gcc-6 go build" -------------------------------------------------------------------------------- /go/log-config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /go/migrations/000001_initial.down.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hednowley/sound/3fa1ee0a0b1e3d15d12557429fa674f9e19ee93d/go/migrations/000001_initial.down.sql -------------------------------------------------------------------------------- /go/migrations/000002_add_playlist_owner.down.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hednowley/sound/3fa1ee0a0b1e3d15d12557429fa674f9e19ee93d/go/migrations/000002_add_playlist_owner.down.sql -------------------------------------------------------------------------------- /go/migrations/000002_add_playlist_owner.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "playlists" ADD COLUMN "owner" TEXT NOT NULL DEFAULT ''; 2 | ALTER TABLE "playlists" ALTER COLUMN "owner" DROP DEFAULT; -------------------------------------------------------------------------------- /go/projectpath/Root.go: -------------------------------------------------------------------------------- 1 | package projectpath 2 | 3 | import ( 4 | "path/filepath" 5 | "runtime" 6 | ) 7 | 8 | var ( 9 | _, b, _, _ = runtime.Caller(0) 10 | 11 | // Root folder of this project 12 | Root = filepath.Join(filepath.Dir(b), "..") 13 | ) 14 | -------------------------------------------------------------------------------- /go/provider/beetsProvider_test.go: -------------------------------------------------------------------------------- 1 | package provider_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/hednowley/sound/provider" 8 | ) 9 | 10 | func Test1(t *testing.T) { 11 | 12 | p, err := provider.NewBeetsProvider("beets", "../testdata/beetslib.blb") 13 | if err != nil { 14 | t.Errorf("Could not make provider: %v", err) 15 | } 16 | 17 | p.Iterate(func(path string) error { 18 | fmt.Println(path) 19 | i, err := p.GetInfo(path) 20 | if err != nil { 21 | t.Errorf("Error getting info for %v: %v", path, err) 22 | } 23 | fmt.Println(i.Title) 24 | 25 | return nil 26 | }) 27 | 28 | } 29 | -------------------------------------------------------------------------------- /go/provider/mockProvider.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/hednowley/sound/entities" 7 | ) 8 | 9 | type MockProvider struct { 10 | files []*entities.FileInfo 11 | id string 12 | isScanning bool 13 | count int 14 | } 15 | 16 | func NewMockProvider(id string, files []*entities.FileInfo) *MockProvider { 17 | return &MockProvider{ 18 | files: files, 19 | id: id, 20 | isScanning: false, 21 | count: 0, 22 | } 23 | } 24 | 25 | func (p *MockProvider) Iterate(callback func(path string) error) error { 26 | p.isScanning = true 27 | p.count = 0 28 | 29 | var callbackErr error 30 | for _, f := range p.files { 31 | callbackErr = callback(f.Path) 32 | p.count = p.count + 1 33 | 34 | if callbackErr != nil { 35 | break 36 | } 37 | } 38 | p.isScanning = false 39 | return callbackErr 40 | } 41 | 42 | func (p *MockProvider) GetInfo(path string) (*entities.FileInfo, error) { 43 | for _, f := range p.files { 44 | if f.Path == path { 45 | return f, nil 46 | } 47 | } 48 | return nil, errors.New("Bad path") 49 | } 50 | 51 | func (p *MockProvider) ID() string { 52 | return p.id 53 | } 54 | 55 | func (p *MockProvider) IsScanning() bool { 56 | return p.isScanning 57 | } 58 | 59 | func (p *MockProvider) FileCount() int64 { 60 | return int64(p.count) 61 | } 62 | -------------------------------------------------------------------------------- /go/services/authenticator_test.go: -------------------------------------------------------------------------------- 1 | package services_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/hednowley/sound/config" 7 | "github.com/hednowley/sound/services" 8 | ) 9 | 10 | var c = config.Config{ 11 | Users: []config.User{ 12 | { 13 | Username: "billy", 14 | Password: "apple tart!!!", 15 | Email: "billy@bigbugs.com", 16 | }, 17 | { 18 | Username: "tom tom", 19 | Password: "sfjksdfjk", 20 | Email: "tom@bigbugs.com", 21 | }, 22 | }, 23 | } 24 | 25 | var a = services.NewAuthenticator(&c) 26 | 27 | func TestPasswordAuth(t *testing.T) { 28 | 29 | // Should work 30 | if a.AuthenticateFromPassword("billy", "apple tart!!!") == nil { 31 | t.Error() 32 | } 33 | 34 | // Shouldn't work 35 | if a.AuthenticateFromPassword("billy2", "apple tart!!!") != nil { 36 | t.Error() 37 | } 38 | 39 | if a.AuthenticateFromPassword("billy", "appletart!!!") != nil { 40 | t.Error() 41 | } 42 | 43 | if a.AuthenticateFromPassword("", "") != nil { 44 | t.Error() 45 | } 46 | } 47 | 48 | func TestTokenAuth(t *testing.T) { 49 | 50 | // Should work 51 | if a.AuthenticateFromToken("tom tom", "saltysalt", "bbf729f2464585f1212e03519659b30a") == nil { 52 | t.Error() 53 | } 54 | 55 | // Shouldn't work 56 | if a.AuthenticateFromToken("tom tom", "saltysalt", "bbf729f2464585f12e03519659b30a") != nil { 57 | t.Error() 58 | } 59 | 60 | if a.AuthenticateFromToken("tom tom", "rocksaltt", "bbf729f2464585f1212e03519659b30a") != nil { 61 | t.Error() 62 | } 63 | 64 | if a.AuthenticateFromToken("", "", "") != nil { 65 | t.Error() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /go/services/clock.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Clock interface { 8 | GetTime() time.Time 9 | } 10 | 11 | type RealClock struct{} 12 | 13 | func (c *RealClock) GetTime() time.Time { 14 | return time.Now() 15 | } 16 | 17 | type MockClock struct{} 18 | -------------------------------------------------------------------------------- /go/services/fileScanner.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | func hasExtension(name string, extensions []string) bool { 10 | for _, e := range extensions { 11 | if strings.HasSuffix(name, "."+e) { 12 | return true 13 | } 14 | } 15 | return false 16 | } 17 | 18 | func IterateFiles(root string, extensions []string, action func(path string, info *os.FileInfo) error) (err error) { 19 | 20 | err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 21 | if !info.IsDir() && hasExtension(info.Name(), extensions) { 22 | return action(path, &info) 23 | } 24 | return nil 25 | }) 26 | if err != nil { 27 | return 28 | } 29 | 30 | return 31 | } 32 | -------------------------------------------------------------------------------- /go/services/fileScanner_test.go: -------------------------------------------------------------------------------- 1 | package services_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/hednowley/sound/services" 11 | ) 12 | 13 | func TestScanner(t *testing.T) { 14 | 15 | errors := []string{} 16 | files := make(map[string]*bool) 17 | 18 | b1 := false 19 | b2 := false 20 | b3 := false 21 | 22 | files[filepath.Join("..", "testdata", "music", "1.mp3")] = &b1 23 | files[filepath.Join("..", "testdata", "music", "2.mp3")] = &b2 24 | files[filepath.Join("..", "testdata", "music", "subfolder", "3.mp3")] = &b3 25 | 26 | e := []string{ 27 | "mp3", 28 | "flac", 29 | } 30 | 31 | path := filepath.Join("..", "testdata", "music") 32 | services.IterateFiles(path, e, func(path string, info *os.FileInfo) error { 33 | for k, v := range files { 34 | if strings.HasSuffix(path, k) { 35 | if *v { 36 | errors = append(errors, fmt.Sprintf("Double scan: %v", k)) 37 | return nil 38 | } 39 | *files[k] = true 40 | return nil 41 | } 42 | } 43 | 44 | errors = append(errors, fmt.Sprintf("Unexpected scan: %v", path)) 45 | 46 | return nil 47 | }) 48 | 49 | if len(errors) > 0 { 50 | for _, v := range errors { 51 | t.Error(v) 52 | } 53 | } 54 | 55 | for k, v := range files { 56 | if !*v { 57 | t.Error(fmt.Sprintf("Unscanned file: %v", k)) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /go/services/resizer.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "image/jpeg" 5 | "image/png" 6 | "os" 7 | 8 | "github.com/nfnt/resize" 9 | ) 10 | 11 | func Resize(originalPath string, newPath string, size uint) (err error) { 12 | 13 | file, err := os.Open(originalPath) 14 | if err != nil { 15 | return err 16 | } 17 | defer file.Close() 18 | 19 | img, err := jpeg.Decode(file) 20 | if err != nil { 21 | img, err = png.Decode(file) 22 | if err != nil { 23 | return err 24 | } 25 | } 26 | 27 | m := resize.Thumbnail(size, size, img, resize.NearestNeighbor) 28 | 29 | out, err := os.Create(newPath) 30 | if err != nil { 31 | return err 32 | } 33 | defer out.Close() 34 | 35 | return jpeg.Encode(out, m, nil) 36 | } 37 | -------------------------------------------------------------------------------- /go/services/tagger.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/dhowden/tag" 7 | "github.com/hednowley/sound/entities" 8 | ) 9 | 10 | // GetMusicData extracts data from a music file's ID3 tags. 11 | func GetMusicData(filePath string) (*entities.FileInfo, error) { 12 | file, _ := os.Open(filePath) 13 | defer file.Close() 14 | 15 | info, err := file.Stat() 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | m, _ := tag.ReadFrom(file) 21 | 22 | track, _ := m.Track() 23 | disc, _ := m.Disc() 24 | 25 | pic := m.Picture() 26 | var art *entities.CoverArtData 27 | if pic != nil { 28 | art = &entities.CoverArtData{ 29 | Extension: pic.Ext, 30 | Raw: pic.Data, 31 | } 32 | } 33 | 34 | var albumArtist string 35 | if len(m.AlbumArtist()) == 0 { 36 | albumArtist = m.Artist() 37 | } else { 38 | albumArtist = m.AlbumArtist() 39 | } 40 | 41 | return &entities.FileInfo{ 42 | Path: filePath, 43 | Artist: m.Artist(), 44 | Album: m.Album(), 45 | AlbumArtist: albumArtist, 46 | Title: m.Title(), 47 | Genre: m.Genre(), 48 | Year: m.Year(), 49 | Track: track, 50 | Disc: disc, 51 | CoverArt: art, 52 | Size: info.Size(), 53 | }, nil 54 | } 55 | -------------------------------------------------------------------------------- /go/services/tagger_test.go: -------------------------------------------------------------------------------- 1 | package services_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/hednowley/sound/services" 7 | ) 8 | 9 | func TestTagReader(t *testing.T) { 10 | d, err := services.GetMusicData("../testdata/music/1.mp3") 11 | if err != nil { 12 | t.Error(err.Error()) 13 | } 14 | 15 | if d.Title != "Front Street (Instrumental)" { 16 | t.Error() 17 | } 18 | 19 | if d.Album != "A Day Wit The Homiez (CD, 2002, RonnieCash.com)" { 20 | t.Error() 21 | } 22 | 23 | if d.AlbumArtist != "1st Down" { 24 | t.Error() 25 | } 26 | 27 | if d.Artist != "1st Down" { 28 | t.Error() 29 | } 30 | 31 | if d.Year != 1995 { 32 | t.Error() 33 | } 34 | 35 | if d.Track != 4 { 36 | t.Error() 37 | } 38 | 39 | if d.Genre != "Hip-Hop" { 40 | t.Error() 41 | } 42 | 43 | if d.Size != 8973172 { 44 | t.Error() 45 | } 46 | 47 | if d.Disc != 0 { 48 | t.Error() 49 | } 50 | } 51 | 52 | func TestReadMissingFile(t *testing.T) { 53 | _, err := services.GetMusicData("../dsaiuhsduihsd/1.mp3") 54 | if err == nil { 55 | t.Error(err.Error()) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /go/socket/connection.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | 7 | "github.com/cihub/seelog" 8 | "github.com/gorilla/websocket" 9 | "github.com/hednowley/sound/socket/dto" 10 | ) 11 | 12 | // Connection is a wrapper around a websocket connection. 13 | type Connection struct { 14 | Inner *websocket.Conn 15 | } 16 | 17 | // NewConnection creates a new connection. 18 | func NewConnection(inner *websocket.Conn) *Connection { 19 | return &Connection{ 20 | Inner: inner, 21 | } 22 | } 23 | 24 | // SendMessage sends the response to the remote client. 25 | func (c *Connection) SendMessage(r *dto.Response) { 26 | 27 | body, err := json.Marshal(r) 28 | if err != nil { 29 | 30 | } 31 | 32 | c.Inner.WriteMessage(websocket.TextMessage, body) 33 | } 34 | 35 | // ReadMessage returns the last message sent from the remote client. 36 | func (c *Connection) ReadMessage() (*dto.Request, error) { 37 | messageType, payload, err := c.Inner.ReadMessage() 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | if messageType != websocket.TextMessage { 43 | return nil, errors.New("Non-text message received") 44 | } 45 | 46 | var r dto.Request 47 | err = json.Unmarshal(payload, &r) 48 | if err != nil { 49 | seelog.Errorf("Unexpected request: %v", string(payload)) 50 | return nil, err 51 | } 52 | 53 | return &r, nil 54 | } 55 | 56 | // Close closes the connection. 57 | func (c *Connection) Close() error { 58 | return c.Inner.Close() 59 | } 60 | -------------------------------------------------------------------------------- /go/socket/dto/album.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/hednowley/sound/dao" 7 | ) 8 | 9 | type Album struct { 10 | ID uint `json:"id"` 11 | Name string `json:"name"` 12 | Artist string `json:"artist"` 13 | ArtistID uint `json:"artistId,string"` 14 | Art string `json:"coverArt,omitempty"` 15 | Created *time.Time `json:"created"` 16 | Year int `json:"year,omitempty"` 17 | Genre string `json:"genre,omitempty"` 18 | Songs []*SongSummary `json:"songs"` 19 | Duration int `json:"duration"` 20 | } 21 | 22 | func NewAlbum(album *dao.Album, songs []dao.Song) *Album { 23 | 24 | songSummaries := make([]*SongSummary, len(songs)) 25 | for index, song := range songs { 26 | songSummaries[index] = NewSongSummary(&song) 27 | } 28 | 29 | return &Album{ 30 | Name: album.Name, 31 | ID: album.ID, 32 | ArtistID: album.ArtistID, 33 | Artist: album.ArtistName, 34 | Art: album.GetArt(), 35 | Created: album.Created, 36 | Genre: album.GetGenre(), 37 | Year: album.GetYear(), 38 | Songs: songSummaries, 39 | Duration: album.Duration, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /go/socket/dto/albumCollection.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import "github.com/hednowley/sound/dao" 4 | 5 | type AlbumCollection struct { 6 | Albums []*AlbumSummary `json:"albums"` 7 | } 8 | 9 | func NewAlbumCollection(albums []dao.Album) *AlbumCollection { 10 | 11 | dtoAlbums := make([]*AlbumSummary, len(albums)) 12 | for index, a := range albums { 13 | dtoAlbums[index] = NewAlbumSummary(&a) 14 | } 15 | 16 | return &AlbumCollection{ 17 | Albums: dtoAlbums, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /go/socket/dto/albumSummary.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/hednowley/sound/dao" 7 | ) 8 | 9 | type AlbumSummary struct { 10 | ID uint `json:"id"` 11 | Name string `json:"name"` 12 | Artist string `json:"artist"` 13 | ArtistID uint `json:"artistId,string"` 14 | Art string `json:"coverArt,omitempty"` 15 | Created *time.Time `json:"created"` 16 | Year int `json:"year,omitempty"` 17 | Genre string `json:"genre,omitempty"` 18 | Duration int `json:"duration"` 19 | } 20 | 21 | func NewAlbumSummary(album *dao.Album) *AlbumSummary { 22 | return &AlbumSummary{ 23 | Name: album.Name, 24 | ID: album.ID, 25 | ArtistID: album.ArtistID, 26 | Artist: album.ArtistName, 27 | Art: album.GetArt(), 28 | Created: album.Created, 29 | Genre: album.GetGenre(), 30 | Year: album.GetYear(), 31 | Duration: album.Duration, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /go/socket/dto/artist.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/hednowley/sound/dao" 5 | ) 6 | 7 | type Artist struct { 8 | ID uint `json:"id"` 9 | Name string `json:"name"` 10 | Art string `json:"coverArt,omitempty"` 11 | Albums []*AlbumSummary `json:"albums"` 12 | } 13 | 14 | func NewArtist(artist *dao.Artist, albums []dao.Album) *Artist { 15 | 16 | albumsDto := make([]*AlbumSummary, len(albums)) 17 | for index, album := range albums { 18 | albumsDto[index] = NewAlbumSummary(&album) 19 | } 20 | 21 | return &Artist{ 22 | ID: artist.ID, 23 | Name: artist.Name, 24 | Albums: albumsDto, 25 | Art: artist.GetArt(), 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /go/socket/dto/artistCollection.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import "github.com/hednowley/sound/dao" 4 | 5 | type ArtistCollection struct { 6 | Artists []*ArtistSummary `json:"artists"` 7 | } 8 | 9 | type ArtistSummary struct { 10 | ID uint `json:"id"` 11 | Name string `json:"name"` 12 | } 13 | 14 | func NewArtistSummary(artist *dao.Artist) *ArtistSummary { 15 | return &ArtistSummary{ 16 | ID: artist.ID, 17 | Name: artist.Name, 18 | } 19 | } 20 | 21 | func NewArtistCollection(artists []dao.Artist) *ArtistCollection { 22 | 23 | dtoArtists := make([]*ArtistSummary, len(artists)) 24 | for index, a := range artists { 25 | dtoArtists[index] = NewArtistSummary(&a) 26 | } 27 | 28 | return &ArtistCollection{ 29 | Artists: dtoArtists, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /go/socket/dto/notification.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type Notification struct { 4 | Method string `json:"method"` 5 | Params map[string]interface{} `json:"params"` 6 | Version string `json:"jsonrpc"` 7 | } 8 | 9 | func NewNotification(method string, params map[string]interface{}) *Notification { 10 | return &Notification{ 11 | Method: method, 12 | Params: params, 13 | Version: "2.0", 14 | } 15 | } 16 | 17 | func NewScanStatusNotification(scanning bool, count int64) *Notification { 18 | return NewNotification("scanStatus", map[string]interface{}{ 19 | "scanning": scanning, 20 | "count": count, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /go/socket/dto/playlist.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/hednowley/sound/dao" 5 | ) 6 | 7 | type Playlist struct { 8 | ID uint `json:"id"` 9 | Name string `json:"name"` 10 | Songs []*SongSummary `json:"songs"` 11 | } 12 | 13 | func NewPlaylist(playlist *dao.Playlist, playlistSongs []dao.Song) *Playlist { 14 | 15 | songs := make([]*SongSummary, len(playlistSongs)) 16 | for index, song := range playlistSongs { 17 | songs[index] = NewSongSummary(&song) 18 | } 19 | 20 | return &Playlist{ 21 | Name: playlist.Name, 22 | ID: playlist.ID, 23 | Songs: songs, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /go/socket/dto/playlistCollection.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/hednowley/sound/dao" 5 | ) 6 | 7 | type playlistSummary struct { 8 | ID uint `json:"id"` 9 | Name string `json:"name"` 10 | } 11 | 12 | func newPlaylistSummary(playlist *dao.Playlist) *playlistSummary { 13 | return &playlistSummary{ 14 | Name: playlist.Name, 15 | ID: playlist.ID, 16 | } 17 | } 18 | 19 | type PlaylistCollection struct { 20 | Playlists []*playlistSummary `json:"playlists"` 21 | } 22 | 23 | func NewPlaylistCollection(playlists []dao.Playlist) *PlaylistCollection { 24 | list := make([]*playlistSummary, len(playlists)) 25 | for index, p := range playlists { 26 | list[index] = newPlaylistSummary(&p) 27 | } 28 | 29 | return &PlaylistCollection{ 30 | Playlists: list, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /go/socket/dto/request.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import "encoding/json" 4 | 5 | type Request struct { 6 | Method string `json:"method"` 7 | Params map[string]*json.RawMessage `json:"params"` 8 | ID int `json:"id"` 9 | Version string `json:"jsonrpc"` 10 | } 11 | -------------------------------------------------------------------------------- /go/socket/dto/response.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | const version = "2.0" 4 | 5 | // Response follows JSON-RPC syntax. 6 | type Response struct { 7 | Error string `json:"error,omitempty"` 8 | Result interface{} `json:"result,omitempty"` 9 | ID int `json:"id,omitempty"` 10 | Version string `json:"jsonrpc"` 11 | } 12 | 13 | func NewErrorResponse(message string, id int) *Response { 14 | return &Response{ 15 | Error: message, 16 | ID: id, 17 | Version: version, 18 | } 19 | } 20 | 21 | func NewErrorNotification(message string) *Response { 22 | return &Response{ 23 | Error: message, 24 | Version: version, 25 | } 26 | } 27 | 28 | func NewResponse(result interface{}, id int) *Response { 29 | return &Response{ 30 | Result: result, 31 | ID: id, 32 | Version: version, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /go/socket/dto/songSummary.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/hednowley/sound/dao" 5 | ) 6 | 7 | type SongSummary struct { 8 | ID uint `json:"id"` 9 | Name string `json:"name"` 10 | Track int `json:"track"` 11 | } 12 | 13 | func NewSongSummary(song *dao.Song) *SongSummary { 14 | return &SongSummary{ 15 | Name: song.Title, 16 | ID: song.ID, 17 | Track: song.Track, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /go/socket/dto/ticketResponse.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type TicketResponse struct { 4 | Accepted bool `json:"accepted"` 5 | } 6 | -------------------------------------------------------------------------------- /go/socket/handler.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "github.com/hednowley/sound/config" 5 | "github.com/hednowley/sound/socket/dto" 6 | ) 7 | 8 | type HandlerContext struct { 9 | User *config.User 10 | } 11 | 12 | // socket.Handler listens for particular websocket messages and 13 | // returns an object which will be sent back to the sender. 14 | type Handler = func(*dto.Request, *HandlerContext) interface{} 15 | -------------------------------------------------------------------------------- /go/socket/handlers/getAlbum.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/hednowley/sound/api/api" 7 | "github.com/hednowley/sound/dal" 8 | "github.com/hednowley/sound/socket" 9 | "github.com/hednowley/sound/socket/dto" 10 | ) 11 | 12 | func MakeGetAlbumHandler(dal *dal.DAL) socket.Handler { 13 | return func(request *dto.Request, _ *socket.HandlerContext) interface{} { 14 | var id uint 15 | 16 | if request.Params["id"] == nil || json.Unmarshal(*request.Params["id"], &id) != nil { 17 | return "bad id" 18 | } 19 | 20 | conn, err := dal.Db.GetConn() 21 | if err != nil { 22 | return api.NewErrorReponse("Cannot make DB conn") 23 | } 24 | defer conn.Release() 25 | 26 | album, err := dal.Db.GetAlbum(conn, id) 27 | if err != nil { 28 | return "no album" 29 | } 30 | 31 | songs, err := dal.Db.GetAlbumSongs(conn, id) 32 | if err != nil { 33 | return api.NewErrorReponse("Error") 34 | } 35 | 36 | return dto.NewAlbum(album, songs) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /go/socket/handlers/getAlbums.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/hednowley/sound/api/api" 5 | "github.com/hednowley/sound/dal" 6 | "github.com/hednowley/sound/dao" 7 | "github.com/hednowley/sound/socket" 8 | "github.com/hednowley/sound/socket/dto" 9 | ) 10 | 11 | func MakeGetAlbumsHandler(dal *dal.DAL) socket.Handler { 12 | return func(request *dto.Request, _ *socket.HandlerContext) interface{} { 13 | conn, err := dal.Db.GetConn() 14 | if err != nil { 15 | return api.NewErrorReponse("Cannot make DB conn") 16 | } 17 | defer conn.Release() 18 | 19 | albums, err := dal.Db.GetAlbums(conn, dao.AlphabeticalByName, 9999999, 0) 20 | if err != nil { 21 | return dto.NewErrorResponse("error", 0) 22 | } 23 | return dto.NewAlbumCollection(albums) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /go/socket/handlers/getArtist.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/hednowley/sound/api/api" 7 | "github.com/hednowley/sound/dal" 8 | "github.com/hednowley/sound/socket" 9 | "github.com/hednowley/sound/socket/dto" 10 | ) 11 | 12 | func MakeGetArtistHandler(dal *dal.DAL) socket.Handler { 13 | return func(request *dto.Request, _ *socket.HandlerContext) interface{} { 14 | var id uint 15 | 16 | if request.Params["id"] == nil || json.Unmarshal(*request.Params["id"], &id) != nil { 17 | return "bad id" 18 | } 19 | 20 | conn, err := dal.Db.GetConn() 21 | if err != nil { 22 | return api.NewErrorReponse("Cannot make DB conn") 23 | } 24 | defer conn.Release() 25 | 26 | artist, err := dal.Db.GetArtist(conn, id) 27 | if err != nil { 28 | return "no artist" 29 | } 30 | 31 | albums, err := dal.Db.GetAlbumsByArtist(conn, id) 32 | if err != nil { 33 | return api.NewErrorReponse("Error") 34 | } 35 | 36 | return dto.NewArtist(artist, albums) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /go/socket/handlers/getArtists.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/hednowley/sound/api/api" 5 | "github.com/hednowley/sound/dal" 6 | "github.com/hednowley/sound/socket" 7 | "github.com/hednowley/sound/socket/dto" 8 | ) 9 | 10 | func MakeGetArtistsHandler(dal *dal.DAL) socket.Handler { 11 | return func(request *dto.Request, _ *socket.HandlerContext) interface{} { 12 | conn, err := dal.Db.GetConn() 13 | if err != nil { 14 | return api.NewErrorReponse("Cannot make DB conn") 15 | } 16 | defer conn.Release() 17 | 18 | artists, err := dal.Db.GetArtists(conn) 19 | if err != nil { 20 | return dto.NewErrorResponse("error", 0) 21 | } 22 | return dto.NewArtistCollection(artists) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /go/socket/handlers/getPlaylist.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/hednowley/sound/api/api" 7 | "github.com/hednowley/sound/dal" 8 | "github.com/hednowley/sound/socket" 9 | "github.com/hednowley/sound/socket/dto" 10 | ) 11 | 12 | func MakeGetPlaylistHandler(dal *dal.DAL) socket.Handler { 13 | return func(request *dto.Request, context *socket.HandlerContext) interface{} { 14 | var id uint 15 | 16 | if request.Params["id"] == nil || json.Unmarshal(*request.Params["id"], &id) != nil { 17 | return "bad id" 18 | } 19 | 20 | conn, err := dal.Db.GetConn() 21 | if err != nil { 22 | return api.NewErrorReponse("Cannot make DB conn") 23 | } 24 | defer conn.Release() 25 | 26 | playlist, err := dal.Db.GetPlaylist(conn, id, context.User.Username) 27 | if err != nil { 28 | return "no playlist" 29 | } 30 | 31 | songs, err := dal.Db.GetPlaylistSongs(conn, id, context.User.Username) 32 | if err != nil { 33 | return "no playlist" 34 | } 35 | 36 | return dto.NewPlaylist(playlist, songs) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /go/socket/handlers/getPlaylists.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/hednowley/sound/api/api" 5 | "github.com/hednowley/sound/dal" 6 | "github.com/hednowley/sound/socket" 7 | "github.com/hednowley/sound/socket/dto" 8 | ) 9 | 10 | func MakeGetPlaylistsHandler(dal *dal.DAL) socket.Handler { 11 | return func(request *dto.Request, context *socket.HandlerContext) interface{} { 12 | conn, err := dal.Db.GetConn() 13 | if err != nil { 14 | return api.NewErrorReponse("Cannot make DB conn") 15 | } 16 | defer conn.Release() 17 | 18 | playlists, err := dal.Db.GetPlaylists(conn, context.User.Username) 19 | if err != nil { 20 | return api.NewErrorReponse("Error") 21 | } 22 | 23 | return dto.NewPlaylistCollection(playlists) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /go/socket/handlers/startScan.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/hednowley/sound/provider" 7 | "github.com/hednowley/sound/socket" 8 | "github.com/hednowley/sound/socket/dto" 9 | ) 10 | 11 | func MakeStartScanHandler(scanner *provider.Scanner) socket.Handler { 12 | return func(request *dto.Request, _ *socket.HandlerContext) interface{} { 13 | var update bool 14 | var delete bool 15 | 16 | if request.Params["update"] == nil || json.Unmarshal(*request.Params["update"], &update) != nil { 17 | update = false 18 | } 19 | 20 | if request.Params["delete"] == nil || json.Unmarshal(*request.Params["delete"], &delete) != nil { 21 | delete = false 22 | } 23 | 24 | go scanner.StartAllScans(update, delete) 25 | return struct{}{} 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /go/socket/mockHub.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/hednowley/sound/socket/dto" 7 | ) 8 | 9 | type MockHub struct { 10 | } 11 | 12 | func NewMockHub() IHub { 13 | return &MockHub{} 14 | } 15 | 16 | func (h *MockHub) SetHandler(method string, handler Handler) { 17 | } 18 | 19 | // Run starts the hub. 20 | func (h *MockHub) Run() { 21 | } 22 | 23 | // Notify sends a notification to all clients. 24 | func (h *MockHub) Notify(notification *dto.Notification) { 25 | } 26 | 27 | // Notify sends a notification to all clients. 28 | func (h *MockHub) AddClient(w http.ResponseWriter, r *http.Request) { 29 | } 30 | -------------------------------------------------------------------------------- /go/socket/ticketer.go: -------------------------------------------------------------------------------- 1 | package socket 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "github.com/hednowley/sound/config" 7 | "time" 8 | ) 9 | 10 | // Ticket allows a user to negotiate a websocket session. 11 | type Ticket struct { 12 | user *config.User 13 | expires time.Time 14 | } 15 | 16 | func (t *Ticket) hasExpired() bool { 17 | return t.expires.Before(time.Now()) 18 | } 19 | 20 | // Ticketer creates and monitors tickets. 21 | type Ticketer struct { 22 | // How long after its creation a ticket expires 23 | duration time.Duration 24 | tickets map[string]Ticket 25 | } 26 | 27 | // NewTicketer creates a new ticketer. 28 | func NewTicketer(config *config.Config) *Ticketer { 29 | return &Ticketer{ 30 | duration: time.Second * time.Duration(config.WebsocketTicketExpiry), 31 | tickets: make(map[string]Ticket), 32 | } 33 | } 34 | 35 | // MakeTicket creates a new ticket. 36 | func (t *Ticketer) MakeTicket(user *config.User) string { 37 | t.cleanTickets() 38 | 39 | b := make([]byte, 20) 40 | rand.Read(b) 41 | s := base64.URLEncoding.EncodeToString(b) 42 | 43 | t.tickets[s] = Ticket{ 44 | expires: time.Now().Add(t.duration), 45 | user: user, 46 | } 47 | 48 | return s 49 | } 50 | 51 | // SubmitTicket sees if there is a ticket with the given key. 52 | // If there is, then the user which created the ticket is returned. 53 | // Otherwise returns nil. 54 | func (t *Ticketer) SubmitTicket(key string) *config.User { 55 | if ticket, ok := t.tickets[key]; ok { 56 | // Delete so ticket can't be used twice 57 | delete(t.tickets, key) 58 | return ticket.user 59 | } 60 | 61 | return nil 62 | } 63 | 64 | // Removes all expired tickets. 65 | func (t *Ticketer) cleanTickets() { 66 | for value, ticket := range t.tickets { 67 | if ticket.hasExpired() { 68 | delete(t.tickets, value) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /go/sound.conf: -------------------------------------------------------------------------------- 1 | start on syno.share.ready and syno.network.ready and started pgsql-adapter 2 | stop on runlevel [06] 3 | 4 | chdir /volume1/other/soundDir 5 | exec ./sound 6 | -------------------------------------------------------------------------------- /go/subsonic/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | 7 | "github.com/hednowley/sound/config" 8 | ) 9 | 10 | var version = "1.16.1" 11 | 12 | type HandlerContext struct { 13 | User *config.User 14 | } 15 | 16 | // Handler is a web controller action. 17 | // It accepts a set of parameters and returns an unserialised Response. 18 | type Handler func(url.Values, *HandlerContext) *Response 19 | 20 | // BinaryHandler is a low-level web controller action. 21 | // It accepts a set of parameters, a ResponseWriter and a Request. 22 | // It returns a nil pointer to indicate that no response is needed, 23 | // otherwise it returns an unserialised Response. 24 | type BinaryHandler func(url.Values, *http.ResponseWriter, *http.Request, *HandlerContext) *Response 25 | -------------------------------------------------------------------------------- /go/subsonic/api/format.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type responseFormat int 4 | 5 | const ( 6 | jsonFormat responseFormat = 0 7 | xmlFormat responseFormat = 1 8 | ) 9 | 10 | var defaultFormat = xmlFormat 11 | -------------------------------------------------------------------------------- /go/subsonic/api/parse.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | ) 7 | 8 | // parseResponseFormat tries to parse a string into a response format. 9 | // If this is not possible then a nil pointer is returned instead. 10 | func parseResponseFormat(param string) *responseFormat { 11 | if len(param) == 0 { 12 | return &defaultFormat 13 | } 14 | 15 | param = strings.ToLower(param) 16 | if param == "json" { 17 | f := jsonFormat 18 | return &f 19 | } 20 | 21 | if param == "xml" { 22 | f := xmlFormat 23 | return &f 24 | } 25 | 26 | return nil 27 | } 28 | 29 | // parseParams extracts parameters from a Request. 30 | // It merges URL queries and the request body 31 | // (body wins where there are collisions) 32 | func parseParams(urlQuery url.Values, body []byte) url.Values { 33 | 34 | values := url.Values{} 35 | 36 | for k, v := range urlQuery { 37 | for _, s := range v { 38 | values.Add(k, s) 39 | } 40 | } 41 | 42 | bodyParams, err := url.ParseQuery(string(body)) 43 | if err == nil { 44 | for k, v := range bodyParams { 45 | for _, s := range v { 46 | values.Add(k, s) 47 | } 48 | } 49 | } 50 | 51 | return values 52 | } 53 | -------------------------------------------------------------------------------- /go/subsonic/api/response.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/hednowley/sound/subsonic/dto" 5 | ) 6 | 7 | type Response struct { 8 | Body interface{} 9 | IsSuccess bool 10 | } 11 | 12 | func NewErrorReponse(code dto.ErrorCode, message string) *Response { 13 | return &Response{ 14 | Body: dto.NewError(code, message), 15 | IsSuccess: false, 16 | } 17 | } 18 | 19 | func NewSuccessfulReponse(body interface{}) *Response { 20 | return &Response{ 21 | Body: body, 22 | IsSuccess: true, 23 | } 24 | } 25 | 26 | func NewEmptyReponse() *Response { 27 | return &Response{ 28 | Body: nil, 29 | IsSuccess: true, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /go/subsonic/api/serialiser.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "fmt" 7 | "reflect" 8 | ) 9 | 10 | func getStatus(succeeded bool) string { 11 | if succeeded { 12 | return "ok" 13 | } 14 | return "failed" 15 | } 16 | 17 | func serialiseToJSON(response *Response) string { 18 | var body *string 19 | var name string 20 | 21 | if response.Body != nil { 22 | b, err := json.Marshal(response.Body) 23 | if err == nil { 24 | s := string(b) 25 | body = &s 26 | 27 | // Dereference response.Body if it is a pointer 28 | v := reflect.ValueOf(response.Body) 29 | if v.Kind() == reflect.Ptr { 30 | v = v.Elem() 31 | } 32 | 33 | nameField, _ := v.Type().FieldByName("XMLName") 34 | name = nameField.Tag.Get("xml") 35 | } 36 | } 37 | 38 | responseJSON, _ := json.Marshal(response.Body) 39 | 40 | status := getStatus(response.IsSuccess) 41 | 42 | if body == nil || len(name) == 0 { 43 | return fmt.Sprintf(`{"subsonic-response":{"status":"%v","version":"%v"}}`, 44 | status, version) 45 | } 46 | 47 | return fmt.Sprintf( 48 | `{"subsonic-response":{"status":"%v","version":"%v", "%v": %s}}`, 49 | status, version, name, responseJSON) 50 | } 51 | 52 | func serialiseToXML(response *Response) string { 53 | var body string 54 | if response.Body != nil { 55 | b, err := xml.Marshal(response.Body) 56 | if err == nil { 57 | body = string(b) 58 | } 59 | } 60 | 61 | return fmt.Sprintf( 62 | `%v`, 63 | getStatus(response.IsSuccess), 64 | version, 65 | body) 66 | } 67 | -------------------------------------------------------------------------------- /go/subsonic/api/serialiser_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | "testing" 7 | ) 8 | 9 | func TestEmptySerialiser(t *testing.T) { 10 | 11 | r := Response{ 12 | Body: nil, 13 | IsSuccess: true, 14 | } 15 | 16 | j := serialiseToJSON(&r) 17 | if j != fmt.Sprintf(`{"subsonic-response":{"status":"ok","version":"%v"}}`, version) { 18 | t.Error() 19 | } 20 | 21 | r.IsSuccess = false 22 | 23 | x := serialiseToXML(&r) 24 | if x != fmt.Sprintf(``, version) { 25 | t.Error() 26 | } 27 | } 28 | 29 | func TestSerialiser(t *testing.T) { 30 | 31 | type TestResponseDto struct { 32 | XMLName xml.Name `xml:"test-thing" json:"-"` 33 | ID uint `xml:"id,attr" json:"id,string"` 34 | Thingy string `xml:"thingy,attr" json:"thingy"` 35 | } 36 | 37 | r := Response{ 38 | Body: &TestResponseDto{ 39 | ID: 5, 40 | Thingy: "dyig", 41 | }, 42 | IsSuccess: false, 43 | } 44 | 45 | j := serialiseToJSON(&r) 46 | if j != fmt.Sprintf(`{"subsonic-response":{"status":"failed","version":"%v", "test-thing": {"id":"5","thingy":"dyig"}}}`, version) { 47 | t.Error() 48 | } 49 | 50 | r.IsSuccess = true 51 | 52 | x := serialiseToXML(&r) 53 | if x != fmt.Sprintf(``, version) { 54 | t.Error() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /go/subsonic/dto/album.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | "time" 6 | 7 | "github.com/hednowley/sound/dao" 8 | ) 9 | 10 | type albumBody struct { 11 | ID uint `xml:"id,attr" json:"id,string"` 12 | Name string `xml:"name,attr" json:"name"` 13 | Artist string `xml:"artist,attr" json:"artist"` 14 | ArtistID uint `xml:"artistId,attr" json:"artistId,string"` 15 | Art string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` 16 | SongCount uint `xml:"songCount,attr" json:"songCount"` 17 | Duration int `xml:"duration,attr" json:"duration"` 18 | Created *time.Time `xml:"created,attr" json:"created"` 19 | Year int `xml:"year,attr,omitempty" json:"year,omitempty"` 20 | Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"` 21 | } 22 | 23 | func newAlbumBody(album *dao.Album) *albumBody { 24 | 25 | return &albumBody{ 26 | ID: album.ID, 27 | Name: album.Name, 28 | ArtistID: album.ArtistID, 29 | SongCount: album.SongCount, 30 | Artist: album.ArtistName, 31 | Art: album.GetArt(), 32 | Created: album.Created, 33 | Genre: album.GetGenre(), 34 | Year: album.GetYear(), 35 | Duration: album.Duration, 36 | } 37 | 38 | } 39 | 40 | type Album struct { 41 | XMLName xml.Name `xml:"album" json:"-"` 42 | *albumBody 43 | } 44 | 45 | func NewAlbum(album *dao.Album) *Album { 46 | return &Album{ 47 | XMLName: xml.Name{}, 48 | albumBody: newAlbumBody(album), 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /go/subsonic/dto/albumDirectory.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/dao" 7 | ) 8 | 9 | type albumDirectoryBody struct { 10 | *Directory 11 | *albumBody 12 | } 13 | 14 | type AlbumDirectory struct { 15 | XMLName xml.Name `xml:"directory" json:"-"` 16 | *albumDirectoryBody 17 | Children []*SongChildDirectory `xml:"child" json:"child"` 18 | } 19 | 20 | type AlbumChildDirectory struct { 21 | XMLName xml.Name `xml:"child" json:"-"` 22 | *albumDirectoryBody 23 | } 24 | 25 | func newAlbumDirectoryBody(album *dao.Album) *albumDirectoryBody { 26 | return &albumDirectoryBody{ 27 | Directory: &Directory{ 28 | ID: NewAlbumID(album.ID), 29 | IsDir: true, 30 | Parent: NewArtistID(album.ArtistID), 31 | }, 32 | albumBody: newAlbumBody(album), 33 | } 34 | } 35 | 36 | func NewAlbumDirectory(album *dao.Album, songs []dao.Song) *AlbumDirectory { 37 | children := make([]*SongChildDirectory, len(songs)) 38 | for index, song := range songs { 39 | children[index] = NewSongChildDirectory(&song) 40 | } 41 | 42 | return &AlbumDirectory{xml.Name{}, newAlbumDirectoryBody(album), children} 43 | } 44 | 45 | func NewAlbumChildDirectory(album *dao.Album) *AlbumChildDirectory { 46 | return &AlbumChildDirectory{xml.Name{}, newAlbumDirectoryBody(album)} 47 | } 48 | -------------------------------------------------------------------------------- /go/subsonic/dto/albumList2.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/dao" 7 | ) 8 | 9 | type AlbumList2 struct { 10 | XMLName xml.Name `xml:"albumList2" json:"-"` 11 | Albums []*Album `xml:"album" json:"album"` 12 | } 13 | 14 | func NewAlbumList2(albums []dao.Album) *AlbumList2 { 15 | 16 | dtoAlbums := make([]*Album, len(albums)) 17 | for index, a := range albums { 18 | dtoAlbums[index] = NewAlbum(&a) 19 | } 20 | 21 | return &AlbumList2{Albums: dtoAlbums} 22 | } 23 | -------------------------------------------------------------------------------- /go/subsonic/dto/albumWithSongs.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/dao" 7 | ) 8 | 9 | type albumWithSongsBody struct { 10 | Songs []*Song `xml:"song" json:"song,omitempty"` 11 | } 12 | 13 | func newAlbumWithSongsBody(album *dao.Album, songs []dao.Song) *albumWithSongsBody { 14 | 15 | songsDto := make([]*Song, len(songs)) 16 | for index, song := range songs { 17 | songsDto[index] = NewSong(&song) 18 | 19 | } 20 | 21 | return &albumWithSongsBody{ 22 | 23 | Songs: songsDto, 24 | } 25 | 26 | } 27 | 28 | type AlbumWithSongs struct { 29 | XMLName xml.Name `xml:"album" json:"-"` 30 | *albumBody 31 | *albumWithSongsBody 32 | } 33 | 34 | func NewAlbumWithSongs(album *dao.Album, songs []dao.Song) *AlbumWithSongs { 35 | return &AlbumWithSongs{ 36 | albumBody: newAlbumBody(album), 37 | albumWithSongsBody: newAlbumWithSongsBody(album, songs), 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /go/subsonic/dto/artist.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/dao" 7 | ) 8 | 9 | type Artist struct { 10 | XMLName xml.Name `xml:"artist" json:"-"` 11 | ID uint `xml:"id,attr" json:"id,string"` 12 | Name string `xml:"name,attr" json:"name"` 13 | Art string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` 14 | AlbumCount uint `xml:"albumCount,attr" json:"albumCount"` 15 | Duration int `xml:"duration,attr" json:"duration"` 16 | } 17 | 18 | func NewArtist(artist *dao.Artist) *Artist { 19 | 20 | return &Artist{ 21 | ID: artist.ID, 22 | Name: artist.Name, 23 | AlbumCount: artist.AlbumCount, 24 | Art: artist.GetArt(), 25 | Duration: artist.Duration, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /go/subsonic/dto/artistDirectory.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/dao" 7 | ) 8 | 9 | type ArtistDirectory struct { 10 | *ArtistDirectorySummary 11 | Children []*AlbumChildDirectory `xml:"child" json:"child"` 12 | } 13 | 14 | type ArtistDirectorySummary struct { 15 | XMLName xml.Name `xml:"directory" json:"-"` 16 | *Directory 17 | Name string `xml:"name,attr" json:"name"` 18 | } 19 | 20 | func NewArtistDirectorySummary(artist *dao.Artist) *ArtistDirectorySummary { 21 | return &ArtistDirectorySummary{ 22 | XMLName: xml.Name{}, 23 | Directory: &Directory{ 24 | ID: NewArtistID(artist.ID), 25 | IsDir: true, 26 | }, 27 | Name: artist.Name, 28 | } 29 | } 30 | 31 | func NewArtistDirectory(artist *dao.Artist, artistAlbums []dao.Album) *ArtistDirectory { 32 | 33 | albums := make([]*AlbumChildDirectory, len(artistAlbums)) 34 | for i, a := range artistAlbums { 35 | albums[i] = NewAlbumChildDirectory(&a) 36 | } 37 | 38 | return &ArtistDirectory{ 39 | NewArtistDirectorySummary(artist), 40 | albums, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /go/subsonic/dto/artistWithAlbums.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/dao" 7 | ) 8 | 9 | // TODO: Extract intersection with Artist 10 | type ArtistWithAlbums struct { 11 | XMLName xml.Name `xml:"artist" json:"-"` 12 | ID uint `xml:"id,attr" json:"id,string"` 13 | Name string `xml:"name,attr" json:"name"` 14 | Art string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` 15 | AlbumCount uint `xml:"albumCount,attr" json:"albumCount"` 16 | Albums []*Album `xml:"album" json:"album,omitempty"` 17 | Duration int `xml:"duration,attr" json:"duration"` 18 | } 19 | 20 | func NewArtistWithAlbums(artist *dao.Artist, albums []dao.Album) *ArtistWithAlbums { 21 | 22 | albumDTOs := make([]*Album, len(albums)) 23 | for index, album := range albums { 24 | albumDTOs[index] = NewAlbum(&album) 25 | } 26 | 27 | return &ArtistWithAlbums{ 28 | ID: artist.ID, 29 | Name: artist.Name, 30 | AlbumCount: artist.AlbumCount, 31 | Albums: albumDTOs, 32 | Art: artist.GetArt(), 33 | Duration: artist.Duration, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /go/subsonic/dto/directory.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type Directory struct { 4 | ID string `xml:"id,attr" json:"id"` 5 | IsDir bool `xml:"isDir,attr" json:"isDir"` 6 | Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"` 7 | } 8 | -------------------------------------------------------------------------------- /go/subsonic/dto/directoryID.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/hednowley/sound/util" 8 | ) 9 | 10 | type DirectoryType int 11 | 12 | const ( 13 | SongDirectoryType DirectoryType = 0 14 | AlbumDirectoryType DirectoryType = 1 15 | ArtistDirectoryType DirectoryType = 2 16 | albumPrefix = "album_" 17 | artistPrefix = "artist_" 18 | ) 19 | 20 | type DirectoryID struct { 21 | ID uint 22 | Type DirectoryType 23 | } 24 | 25 | func ParseDirectoryID(s string) (*DirectoryID, error) { 26 | if strings.HasPrefix(s, albumPrefix) { 27 | id := util.ParseUint(strings.TrimPrefix(s, albumPrefix), 0) 28 | if id == 0 { 29 | return nil, errors.New("Bad album ID") 30 | } 31 | return &DirectoryID{id, AlbumDirectoryType}, nil 32 | } 33 | 34 | if strings.HasPrefix(s, artistPrefix) { 35 | id := util.ParseUint(strings.TrimPrefix(s, artistPrefix), 0) 36 | if id == 0 { 37 | return nil, errors.New("Bad artist ID") 38 | } 39 | return &DirectoryID{id, ArtistDirectoryType}, nil 40 | } 41 | 42 | id := util.ParseUint(s, 0) 43 | if id == 0 { 44 | return nil, errors.New("Unknown directory ID") 45 | } 46 | return &DirectoryID{id, SongDirectoryType}, nil 47 | 48 | } 49 | 50 | func NewSongID(id uint) string { 51 | return util.FormatUint(id) 52 | } 53 | 54 | func NewAlbumID(id uint) string { 55 | return albumPrefix + util.FormatUint(id) 56 | } 57 | 58 | func NewArtistID(id uint) string { 59 | return artistPrefix + util.FormatUint(id) 60 | } 61 | -------------------------------------------------------------------------------- /go/subsonic/dto/error.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | ) 6 | 7 | type Error struct { 8 | XMLName xml.Name `xml:"error" json:"-"` 9 | Code int `xml:"code,attr" json:"code"` 10 | Message string `xml:"message,attr" json:"message"` 11 | } 12 | 13 | type ErrorCode int 14 | 15 | const ( 16 | Generic ErrorCode = 0 17 | MissingParameter ErrorCode = 10 18 | ClientTooOld ErrorCode = 20 19 | ServerTooOld ErrorCode = 30 20 | WrongCredentials ErrorCode = 40 21 | TokenAuthUnsupported ErrorCode = 41 22 | NotAuthorised ErrorCode = 50 23 | BadLicense ErrorCode = 60 24 | NotFound ErrorCode = 70 25 | ) 26 | 27 | func NewError(code ErrorCode, message string) Error { 28 | 29 | return Error{ 30 | Code: int(code), 31 | Message: message, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /go/subsonic/dto/genres.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/dao" 7 | ) 8 | 9 | type Genres struct { 10 | XMLName xml.Name `xml:"genres" json:"-"` 11 | Genres []Genre `xml:"genre" json:"genre"` 12 | } 13 | 14 | type Genre struct { 15 | XMLName xml.Name `xml:"genre" json:"-"` 16 | SongCount int `xml:"songCount,attr" json:"songCount"` 17 | AlbumCount int `xml:"albumCount,attr" json:"albumCount"` 18 | Name string `xml:",chardata" json:"value"` 19 | } 20 | 21 | func NewGenre(genre *dao.Genre) *Genre { 22 | return &Genre{ 23 | SongCount: genre.SongCount, 24 | AlbumCount: genre.AlbumCount, 25 | Name: genre.Name, 26 | } 27 | } 28 | 29 | func NewGenres(genres []dao.Genre) *Genres { 30 | 31 | count := len(genres) 32 | dtoGenres := make([]Genre, count) 33 | 34 | for i, g := range genres { 35 | dtoGenres[i] = *NewGenre(&g) 36 | } 37 | 38 | return &Genres{ 39 | Genres: dtoGenres, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /go/subsonic/dto/genres_test.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/hednowley/sound/database" 7 | ) 8 | 9 | func TestGenres(t *testing.T) { 10 | 11 | // genres := GenerateGenres(4) 12 | // art := GenerateArt(1) 13 | // artist := GenerateArtist(1, art) 14 | // albums1 := GenerateAlbums(5, &genres[0], artist, art) 15 | // GenerateAlbums(3, &genres[1], artist, art) 16 | // GenerateAlbums(1, &genres[2], artist, art) 17 | 18 | // GenerateSongs(1, &genres[0], &albums1[0], art) 19 | // GenerateSongs(2, &genres[1], &albums1[0], art) 20 | // GenerateSongs(3, &genres[2], &albums1[0], art) 21 | 22 | db := database.NewMock() 23 | conn, _ := db.GetConn() 24 | defer conn.Release() 25 | 26 | genres, err := db.GetGenres(conn) 27 | if err != nil { 28 | t.Error(err) 29 | } 30 | 31 | DTO := NewGenres(genres) 32 | 33 | xml := ` 34 | 35 | genre_1 36 | genre_2 37 | genre_4 38 | 39 | ` 40 | 41 | json := ` 42 | { 43 | "genre":[ 44 | { 45 | "songCount":3, 46 | "albumCount":3, 47 | "value":"genre_1" 48 | }, 49 | { 50 | "songCount":1, 51 | "albumCount":1, 52 | "value":"genre_2" 53 | }, 54 | { 55 | "songCount":0, 56 | "albumCount":0, 57 | "value":"genre_4" 58 | } 59 | ] 60 | } 61 | ` 62 | 63 | err = CheckSerialisation(DTO, xml, json) 64 | if err != nil { 65 | t.Error(err.Error()) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /go/subsonic/dto/license.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | ) 6 | 7 | // License is a minimal Subsonic software license. 8 | type License struct { 9 | XMLName xml.Name `xml:"license" json:"-"` 10 | Valid bool `xml:"valid,attr" json:"valid"` 11 | } 12 | 13 | // NewLicense makes a new License. 14 | func NewLicense() *License { 15 | return &License{ 16 | xml.Name{}, 17 | true, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /go/subsonic/dto/musicFolderCollection.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/provider" 7 | ) 8 | 9 | type MusicFolderCollection struct { 10 | XMLName xml.Name `xml:"musicFolders" json:"-"` 11 | Folders []*musicFolder `xml:"musicFolder,attr" json:"musicFolder"` 12 | } 13 | 14 | type musicFolder struct { 15 | XMLName xml.Name `xml:"musicFolder" json:"-"` 16 | ID int `xml:"id,attr" json:"id"` 17 | Name string `xml:"name" json:"name"` 18 | } 19 | 20 | func NewMusicFolderCollection(providers []provider.Provider) *MusicFolderCollection { 21 | 22 | folders := make([]*musicFolder, len(providers)) 23 | for i, p := range providers { 24 | folders[i] = &musicFolder{ID: i, Name: p.ID()} 25 | } 26 | 27 | return &MusicFolderCollection{Folders: folders} 28 | } 29 | -------------------------------------------------------------------------------- /go/subsonic/dto/playlistCollection.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/dao" 7 | ) 8 | 9 | type PlaylistCollection struct { 10 | XMLName xml.Name `xml:"playlists" json:"-"` 11 | Playlists []*PlaylistCollectionItem `xml:"playlist" json:"playlist"` 12 | } 13 | 14 | type PlaylistCollectionItem struct { 15 | XMLName xml.Name `xml:"playlist" json:"-"` 16 | *PlaylistCore 17 | } 18 | 19 | func NewPlaylistCollectionItem(playlist *dao.Playlist) *PlaylistCollectionItem { 20 | return &PlaylistCollectionItem{ 21 | xml.Name{}, 22 | newPlaylistCore(playlist), 23 | } 24 | } 25 | 26 | func NewPlaylistCollection(playlists []dao.Playlist) *PlaylistCollection { 27 | 28 | count := len(playlists) 29 | dtoCollection := make([]*PlaylistCollectionItem, count) 30 | 31 | for i, p := range playlists { 32 | dtoCollection[i] = NewPlaylistCollectionItem(&p) 33 | } 34 | 35 | return &PlaylistCollection{ 36 | Playlists: dtoCollection, 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /go/subsonic/dto/playlistCollection_test.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | /* 4 | var playlistCollectionDTO = NewPlaylistCollection([]dao.Playlist{ 5 | playlist1, 6 | playlist2, 7 | playlist3, 8 | }) 9 | 10 | func TestPlaylistCollectionXml(t *testing.T) { 11 | 12 | body, err := xml.Marshal(playlistCollectionDTO) 13 | if err != nil { 14 | t.Error(err.Error()) 15 | } 16 | 17 | if string(body) != `` { 18 | fmt.Println(string(body)) 19 | t.Error("Bad serialisation") 20 | } 21 | 22 | } 23 | 24 | func TestPlaylistCollectionJson(t *testing.T) { 25 | body, err := json.Marshal(playlistCollectionDTO) 26 | if err != nil { 27 | t.Error(err.Error()) 28 | } 29 | 30 | if string(body) != `{"playlist":[{"id":1,"songCount":3,"name":"fghgfd hfg","public":true,"created":"2014-11-12T11:45:26.371Z","changed":"2014-11-12T11:45:26.371Z","owner":"ned","comment":"hufdggdsh"},{"id":2,"songCount":1,"name":"iuafghfd dhsd","public":false,"created":"2014-11-12T11:45:26.371Z","changed":"2014-11-12T11:45:26.371Z","owner":"ned","comment":"fhfgh"},{"id":3,"songCount":0,"name":"fhgfh","public":true,"created":"2014-11-12T11:45:26.371Z","changed":"2014-11-12T11:45:26.371Z","owner":"ned","comment":"dgf"}]}` { 31 | fmt.Println(string(body)) 32 | t.Error("Bad serialisation") 33 | } 34 | 35 | } 36 | 37 | */ 38 | -------------------------------------------------------------------------------- /go/subsonic/dto/randomSongs.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/dao" 7 | ) 8 | 9 | type RandomSongs struct { 10 | XMLName xml.Name `xml:"randomSongs" json:"-"` 11 | Songs []*Song `xml:"song" json:"song"` 12 | } 13 | 14 | func NewRandomSongs(songs []dao.Song) *RandomSongs { 15 | 16 | dto := make([]*Song, len(songs)) 17 | 18 | for i, s := range songs { 19 | dto[i] = NewSong(&s) 20 | } 21 | return &RandomSongs{ 22 | Songs: dto, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /go/subsonic/dto/randomSongs_test.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "fmt" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestRandomSongs(t *testing.T) { 12 | 13 | genre := GenerateGenre(1) 14 | art := GenerateArt(1) 15 | artist := GenerateArtist(1, art) 16 | album := GenerateAlbum(1, genre, artist, art) 17 | songs := GenerateSongs(5, genre, album, art) 18 | 19 | DTO := NewRandomSongs(songs) 20 | 21 | innerXML := "" 22 | for _, s := range songs { 23 | m, _ := xml.Marshal(NewSong(&s)) 24 | innerXML += string(m) 25 | } 26 | 27 | xml := fmt.Sprintf(` 28 | %v 29 | `, innerXML) 30 | 31 | innerJSON := make([]string, 5) 32 | for i, s := range songs { 33 | m, _ := json.Marshal(NewSong(&s)) 34 | innerJSON[i] = string(m) 35 | } 36 | 37 | json := fmt.Sprintf(` 38 | { 39 | "song":[%v] 40 | } 41 | `, strings.Join(innerJSON, ",")) 42 | 43 | err := CheckSerialisation(DTO, xml, json) 44 | if err != nil { 45 | t.Error(err.Error()) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /go/subsonic/dto/scanStatus.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | ) 6 | 7 | // ScanStatus describes the progress of all music scans. 8 | type ScanStatus struct { 9 | XMLName xml.Name `xml:"scanStatus" json:"-"` 10 | Scanning bool `xml:"scanning,attr" json:"scanning"` 11 | Count int64 `xml:"count,attr" json:"count"` 12 | } 13 | 14 | // NewScanStatus makes a new ScanStatus DTO. 15 | func NewScanStatus(scanning bool, count int64) ScanStatus { 16 | return ScanStatus{ 17 | Scanning: scanning, 18 | Count: count, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /go/subsonic/dto/scanStatus_test.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestScanningStatus(t *testing.T) { 8 | 9 | DTO := NewScanStatus(true, 45485) 10 | 11 | xml := `` 12 | 13 | json := ` 14 | { 15 | "scanning":true, 16 | "count":45485 17 | } 18 | ` 19 | 20 | err := CheckSerialisation(DTO, xml, json) 21 | if err != nil { 22 | t.Error(err.Error()) 23 | } 24 | } 25 | 26 | func TestNotScanningStatus(t *testing.T) { 27 | 28 | DTO := NewScanStatus(false, 0) 29 | 30 | xml := `` 31 | 32 | json := ` 33 | { 34 | "scanning":false, 35 | "count":0 36 | } 37 | ` 38 | 39 | err := CheckSerialisation(DTO, xml, json) 40 | if err != nil { 41 | t.Error(err.Error()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /go/subsonic/dto/search.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/dao" 7 | ) 8 | 9 | type SearchCore struct { 10 | Artists []*Artist `xml:"artist" json:"artist"` 11 | Albums []*Album `xml:"album" json:"album"` 12 | Songs []*Song `xml:"song" json:"song"` 13 | } 14 | 15 | type Search2Response struct { 16 | XMLName xml.Name `xml:"searchResult2" json:"-"` 17 | *SearchCore 18 | } 19 | 20 | type Search3Response struct { 21 | XMLName xml.Name `xml:"searchResult3" json:"-"` 22 | *SearchCore 23 | } 24 | 25 | func newSearchResponse(artists []dao.Artist, albums []dao.Album, songs []dao.Song) *SearchCore { 26 | 27 | artistCount := len(artists) 28 | artistsDto := make([]*Artist, artistCount) 29 | 30 | for i, a := range artists { 31 | artistsDto[i] = NewArtist(&a) 32 | } 33 | 34 | albumCount := len(albums) 35 | albumsDto := make([]*Album, albumCount) 36 | 37 | for i, a := range albums { 38 | albumsDto[i] = NewAlbum(&a) 39 | } 40 | 41 | songCount := len(songs) 42 | songsDto := make([]*Song, songCount) 43 | 44 | for i, a := range songs { 45 | songsDto[i] = NewSong(&a) 46 | } 47 | 48 | return &SearchCore{ 49 | artistsDto, 50 | albumsDto, 51 | songsDto, 52 | } 53 | } 54 | 55 | func NewSearch2Response(artists []dao.Artist, albums []dao.Album, songs []dao.Song) *Search2Response { 56 | core := newSearchResponse(artists, albums, songs) 57 | return &Search2Response{ 58 | xml.Name{}, 59 | core, 60 | } 61 | } 62 | 63 | func NewSearch3Response(artists []dao.Artist, albums []dao.Album, songs []dao.Song) *Search3Response { 64 | core := newSearchResponse(artists, albums, songs) 65 | return &Search3Response{ 66 | xml.Name{}, 67 | core, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /go/subsonic/dto/songDirectory.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/dao" 7 | ) 8 | 9 | type songDirectoryBody struct { 10 | *Directory 11 | *songBody 12 | Name string `xml:"name,attr" json:"name"` 13 | } 14 | 15 | type SongDirectory struct { 16 | XMLName xml.Name `xml:"directory" json:"-"` 17 | *songDirectoryBody 18 | } 19 | 20 | type SongChildDirectory struct { 21 | XMLName xml.Name `xml:"child" json:"-"` 22 | *songDirectoryBody 23 | } 24 | 25 | func newSongDirectoryBody(song *dao.Song) *songDirectoryBody { 26 | return &songDirectoryBody{ 27 | Directory: &Directory{ 28 | ID: NewSongID(song.ID), 29 | IsDir: false, 30 | Parent: NewAlbumID(song.AlbumID), 31 | }, 32 | songBody: newSongBody(song), 33 | Name: song.Title, 34 | } 35 | } 36 | 37 | func NewSongDirectory(song *dao.Song) *SongDirectory { 38 | return &SongDirectory{xml.Name{}, newSongDirectoryBody(song)} 39 | } 40 | 41 | func NewSongChildDirectory(song *dao.Song) *SongChildDirectory { 42 | return &SongChildDirectory{xml.Name{}, newSongDirectoryBody(song)} 43 | } 44 | -------------------------------------------------------------------------------- /go/subsonic/dto/songsByGenre.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/dao" 7 | ) 8 | 9 | type SongsByGenre struct { 10 | XMLName xml.Name `xml:"songsByGenre" json:"-"` 11 | Songs []*Song `xml:"song" json:"song"` 12 | } 13 | 14 | func NewSongsByGenre(songs []dao.Song) *SongsByGenre { 15 | 16 | songDTOs := make([]*Song, len(songs)) 17 | 18 | for i, s := range songs { 19 | songDTOs[i] = NewSong(&s) 20 | } 21 | return &SongsByGenre{ 22 | Songs: songDTOs, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /go/subsonic/dto/user.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/config" 7 | ) 8 | 9 | type User struct { 10 | XMLName xml.Name `xml:"user" json:"-"` 11 | Username string `xml:"username,attr" json:"username"` 12 | Email string `xml:"email,attr" json:"email"` 13 | ScrobblingEnabled bool `xml:"scrobblingEnabled,attr" json:"scrobblingEnabled"` 14 | AdminRole bool `xml:"adminRole,attr" json:"adminRole"` 15 | SettingsRole bool `xml:"settingsRole,attr" json:"settingsRole"` 16 | DownloadRole bool `xml:"downloadRole,attr" json:"downloadRole"` 17 | UploadRole bool `xml:"uploadRole,attr" json:"uploadRole"` 18 | PlaylistRole bool `xml:"playlistRole,attr" json:"playlistRole"` 19 | CoverArtRole bool `xml:"coverArtRole,attr" json:"coverArtRole"` 20 | CommentRole bool `xml:"commentRole,attr" json:"commentRole"` 21 | PodcastRole bool `xml:"podcastRole,attr" json:"podcastRole"` 22 | StreamRole bool `xml:"streamRole,attr" json:"streamRole"` 23 | JukeboxRole bool `xml:"jukeboxRole,attr" json:"jukeboxRole"` 24 | ShareRole bool `xml:"shareRole,attr" json:"shareRole"` 25 | VideoConversionRole bool `xml:"videoConversionRole,attr" json:"videoConversionRole"` 26 | Folder []uint `xml:"folder,attr" json:"folder"` 27 | } 28 | 29 | func NewUser(user config.User) User { 30 | return User{ 31 | Username: user.Username, 32 | Email: user.Email, 33 | AdminRole: true, 34 | SettingsRole: true, 35 | DownloadRole: true, 36 | UploadRole: true, 37 | PlaylistRole: true, 38 | StreamRole: true, 39 | Folder: []uint{0}, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /go/subsonic/dto/userCollection.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "encoding/xml" 5 | 6 | "github.com/hednowley/sound/config" 7 | ) 8 | 9 | type UserCollection struct { 10 | XMLName xml.Name `xml:"users" json:"-"` 11 | Users []User `xml:"user" json:"user"` 12 | } 13 | 14 | func NewUserCollection(users []config.User) UserCollection { 15 | 16 | col := make([]User, len(users)) 17 | for index, user := range users { 18 | col[index] = NewUser(user) 19 | } 20 | 21 | return UserCollection{ 22 | Users: col, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /go/subsonic/dto/user_test.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/hednowley/sound/config" 7 | ) 8 | 9 | func TestUser(t *testing.T) { 10 | 11 | user := config.User{ 12 | Username: "dsauid", 13 | Email: "sdfhjfsd@dsid.com", 14 | } 15 | 16 | DTO := NewUser(user) 17 | 18 | xml := ` 19 | 20 | ` 21 | 22 | json := ` 23 | { 24 | "username":"dsauid", 25 | "email":"sdfhjfsd@dsid.com", 26 | "scrobblingEnabled":false, 27 | "adminRole":true, 28 | "settingsRole":true, 29 | "downloadRole":true, 30 | "uploadRole":true, 31 | "playlistRole":true, 32 | "coverArtRole":false, 33 | "commentRole":false, 34 | "podcastRole":false, 35 | "streamRole":true, 36 | "jukeboxRole":false, 37 | "shareRole":false, 38 | "videoConversionRole":false, 39 | "folder":[0] 40 | } 41 | ` 42 | 43 | err := CheckSerialisation(DTO, xml, json) 44 | if err != nil { 45 | t.Error(err.Error()) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /go/subsonic/handler/deletePlaylist.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | "github.com/hednowley/sound/dal" 8 | "github.com/hednowley/sound/dao" 9 | "github.com/hednowley/sound/subsonic/api" 10 | "github.com/hednowley/sound/subsonic/dto" 11 | "github.com/hednowley/sound/util" 12 | ) 13 | 14 | // NewDeletePlaylistHandler does http://www.subsonic.org/pages/api.jsp#deletePlaylist 15 | func NewDeletePlaylistHandler(dal *dal.DAL) api.Handler { 16 | 17 | return func(params url.Values, context *api.HandlerContext) *api.Response { 18 | 19 | idParam := params.Get("id") 20 | id := util.ParseUint(idParam, 0) 21 | if id == 0 { 22 | return api.NewErrorReponse(dto.MissingParameter, "Required param (id) is missing") 23 | } 24 | 25 | conn, err := dal.Db.GetConn() 26 | if err != nil { 27 | return api.NewErrorReponse(dto.Generic, err.Error()) 28 | } 29 | defer conn.Release() 30 | 31 | err = dal.Db.DeletePlaylist(conn, id, context.User.Username) 32 | if err != nil { 33 | if _, ok := err.(*dao.ErrNotFound); ok { 34 | message := fmt.Sprintf("Playlist not found: %v", idParam) 35 | return api.NewErrorReponse(dto.NotFound, message) 36 | } 37 | return api.NewErrorReponse(dto.NotFound, err.Error()) 38 | } 39 | 40 | return api.NewSuccessfulReponse(nil) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /go/subsonic/handler/download.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/hednowley/sound/dal" 9 | "github.com/hednowley/sound/subsonic/api" 10 | "github.com/hednowley/sound/subsonic/dto" 11 | "github.com/hednowley/sound/util" 12 | ) 13 | 14 | // NewDownloadHandler does http://www.subsonic.org/pages/api.jsp#download 15 | func NewDownloadHandler(dal *dal.DAL) api.BinaryHandler { 16 | 17 | return func(params url.Values, w *http.ResponseWriter, r *http.Request, _ *api.HandlerContext) *api.Response { 18 | 19 | idParam := params.Get("id") 20 | id := util.ParseUint(idParam, 0) 21 | if id == 0 { 22 | return api.NewErrorReponse(dto.Generic, fmt.Sprintf("Song not found: %v", idParam)) 23 | } 24 | 25 | conn, err := dal.Db.GetConn() 26 | if err != nil { 27 | return api.NewErrorReponse(dto.Generic, err.Error()) 28 | } 29 | defer conn.Release() 30 | 31 | path := dal.Db.GetSongPath(conn, id) 32 | if path != nil { 33 | return api.NewErrorReponse(dto.Generic, "song not found") 34 | } 35 | 36 | http.ServeFile(*w, r, *path) 37 | return nil 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /go/subsonic/handler/getAlbum.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/dal" 7 | "github.com/hednowley/sound/dao" 8 | "github.com/hednowley/sound/subsonic/api" 9 | "github.com/hednowley/sound/subsonic/dto" 10 | "github.com/hednowley/sound/util" 11 | ) 12 | 13 | // NewGetAlbumHandler does http://www.subsonic.org/pages/api.jsp#getAlbum 14 | func NewGetAlbumHandler(dal *dal.DAL) api.Handler { 15 | 16 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 17 | 18 | idParam := params.Get("id") 19 | id := util.ParseUint(idParam, 0) 20 | if id == 0 { 21 | return api.NewErrorReponse(dto.MissingParameter, "Required param (id) is missing") 22 | } 23 | 24 | conn, err := dal.Db.GetConn() 25 | if err != nil { 26 | return api.NewErrorReponse(dto.Generic, err.Error()) 27 | } 28 | defer conn.Release() 29 | 30 | album, err := dal.Db.GetAlbum(conn, id) 31 | if err != nil { 32 | if _, ok := err.(*dao.ErrNotFound); ok { 33 | return api.NewErrorReponse(dto.NotFound, "Album not found.") 34 | } 35 | return api.NewErrorReponse(dto.Generic, err.Error()) 36 | } 37 | 38 | songs, err := dal.Db.GetAlbumSongs(conn, id) 39 | if err != nil { 40 | return api.NewErrorReponse(dto.Generic, err.Error()) 41 | } 42 | 43 | return api.NewSuccessfulReponse(dto.NewAlbumWithSongs(album, songs)) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /go/subsonic/handler/getArtist.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/dal" 7 | "github.com/hednowley/sound/dao" 8 | "github.com/hednowley/sound/subsonic/api" 9 | "github.com/hednowley/sound/subsonic/dto" 10 | "github.com/hednowley/sound/util" 11 | ) 12 | 13 | // NewGetArtistHandler does http://www.subsonic.org/pages/api.jsp#getArtist 14 | func NewGetArtistHandler(dal *dal.DAL) api.Handler { 15 | 16 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 17 | 18 | idParam := params.Get("id") 19 | id := util.ParseUint(idParam, 0) 20 | if id == 0 { 21 | return api.NewErrorReponse(dto.MissingParameter, "Required param (id) is missing") 22 | } 23 | 24 | conn, err := dal.Db.GetConn() 25 | if err != nil { 26 | return api.NewErrorReponse(dto.Generic, err.Error()) 27 | } 28 | defer conn.Release() 29 | 30 | artist, err := dal.Db.GetArtist(conn, id) 31 | if err != nil { 32 | if _, ok := err.(*dao.ErrNotFound); ok { 33 | return api.NewErrorReponse(dto.NotFound, "Artist not found.") 34 | } 35 | return api.NewErrorReponse(dto.Generic, err.Error()) 36 | } 37 | 38 | albums, err := dal.Db.GetAlbumsByArtist(conn, id) 39 | if err != nil { 40 | return api.NewErrorReponse(dto.Generic, err.Error()) 41 | } 42 | 43 | return api.NewSuccessfulReponse(dto.NewArtistWithAlbums(artist, albums)) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /go/subsonic/handler/getArtists.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/config" 7 | "github.com/hednowley/sound/dal" 8 | "github.com/hednowley/sound/subsonic/api" 9 | "github.com/hednowley/sound/subsonic/dto" 10 | ) 11 | 12 | func NewGetArtistsHandler(dal *dal.DAL, conf *config.Config) api.Handler { 13 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 14 | conn, err := dal.Db.GetConn() 15 | if err != nil { 16 | return api.NewErrorReponse(dto.Generic, err.Error()) 17 | } 18 | defer conn.Release() 19 | 20 | artists, err := dal.Db.GetArtists(conn) 21 | if err != nil { 22 | return api.NewErrorReponse(0, "Error") 23 | } 24 | return api.NewSuccessfulReponse(dto.NewArtistCollection(artists, conf)) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /go/subsonic/handler/getArtists_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/hednowley/sound/config" 8 | "github.com/hednowley/sound/dal" 9 | "github.com/hednowley/sound/subsonic/api" 10 | "github.com/hednowley/sound/subsonic/dto" 11 | ) 12 | 13 | func TestGetArtists(t *testing.T) { 14 | 15 | db := dal.NewMock() 16 | handler := NewGetArtistsHandler(db, &config.Config{}) 17 | params := url.Values{} 18 | url.Values.Add(params, "id", "1") 19 | 20 | context := api.HandlerContext{} 21 | 22 | response := handler(params, &context) 23 | 24 | if !response.IsSuccess { 25 | t.Error("Not a success") 26 | } 27 | 28 | _, ok := response.Body.(*dto.ArtistCollection) 29 | if !ok { 30 | t.Error("Not an artist collection") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /go/subsonic/handler/getCoverArt.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | 7 | "github.com/hednowley/sound/dal" 8 | "github.com/hednowley/sound/subsonic/api" 9 | "github.com/hednowley/sound/subsonic/dto" 10 | "github.com/hednowley/sound/util" 11 | ) 12 | 13 | func NewGetCoverArtHandler(dal *dal.DAL) api.BinaryHandler { 14 | 15 | return func(params url.Values, w *http.ResponseWriter, r *http.Request, _ *api.HandlerContext) *api.Response { 16 | 17 | id := params.Get("id") 18 | if id == "" { 19 | return api.NewErrorReponse(dto.NotFound, "Art parameter missing") 20 | } 21 | 22 | sizeParam := params.Get("size") 23 | size := util.ParseUint(sizeParam, 0) 24 | 25 | path := dal.GetArt(id, size) 26 | if path == nil { 27 | return api.NewErrorReponse(dto.Generic, "Art is not available") 28 | } 29 | 30 | http.ServeFile(*w, r, *path) 31 | return nil 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /go/subsonic/handler/getGenres.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/dal" 7 | "github.com/hednowley/sound/subsonic/api" 8 | "github.com/hednowley/sound/subsonic/dto" 9 | ) 10 | 11 | // NewGetGenresHandler does http://www.subsonic.org/pages/api.jsp#getGenres 12 | func NewGetGenresHandler(dal *dal.DAL) api.Handler { 13 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 14 | conn, err := dal.Db.GetConn() 15 | if err != nil { 16 | return api.NewErrorReponse(dto.Generic, err.Error()) 17 | } 18 | defer conn.Release() 19 | 20 | genres, err := dal.Db.GetGenres(conn) 21 | if err != nil { 22 | return api.NewErrorReponse(dto.Generic, err.Error()) 23 | } 24 | 25 | return api.NewSuccessfulReponse(dto.NewGenres(genres)) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /go/subsonic/handler/getIndexes.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/config" 7 | "github.com/hednowley/sound/dal" 8 | "github.com/hednowley/sound/subsonic/api" 9 | "github.com/hednowley/sound/subsonic/dto" 10 | ) 11 | 12 | // NewGetIndexesHandler does http://www.subsonic.org/pages/api.jsp#getIndexes 13 | func NewGetIndexesHandler(dal *dal.DAL, conf *config.Config) api.Handler { 14 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 15 | conn, err := dal.Db.GetConn() 16 | if err != nil { 17 | return api.NewErrorReponse(dto.Generic, err.Error()) 18 | } 19 | defer conn.Release() 20 | 21 | artists, err := dal.Db.GetArtists(conn) 22 | if err != nil { 23 | return api.NewErrorReponse(0, "Error") 24 | } 25 | return api.NewSuccessfulReponse(dto.NewIndexCollection(artists, conf)) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /go/subsonic/handler/getLicense.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/subsonic/api" 7 | "github.com/hednowley/sound/subsonic/dto" 8 | ) 9 | 10 | func NewGetLicenseHandler() api.Handler { 11 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 12 | return api.NewSuccessfulReponse(dto.NewLicense()) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /go/subsonic/handler/getMusicDirectory_test.go: -------------------------------------------------------------------------------- 1 | package handler_test 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/hednowley/sound/dal" 8 | "github.com/hednowley/sound/subsonic/api" 9 | "github.com/hednowley/sound/subsonic/handler" 10 | ) 11 | 12 | func TestGetArtistDirectory(t *testing.T) { 13 | 14 | dal := dal.NewMock() 15 | handler := handler.NewGetMusicDirectoryHandler(dal) 16 | params := url.Values{} 17 | url.Values.Add(params, "id", "artist_1") 18 | 19 | context := api.HandlerContext{} 20 | 21 | handler(params, &context) 22 | 23 | // TODO 24 | } 25 | -------------------------------------------------------------------------------- /go/subsonic/handler/getMusicFolders.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/provider" 7 | "github.com/hednowley/sound/subsonic/api" 8 | "github.com/hednowley/sound/subsonic/dto" 9 | ) 10 | 11 | func NewGetMusicFoldersHandler(providers []provider.Provider) api.Handler { 12 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 13 | return api.NewSuccessfulReponse(dto.NewMusicFolderCollection(providers)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /go/subsonic/handler/getPlaylist.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | "github.com/hednowley/sound/dal" 8 | "github.com/hednowley/sound/subsonic/api" 9 | "github.com/hednowley/sound/subsonic/dto" 10 | "github.com/hednowley/sound/util" 11 | ) 12 | 13 | func NewGetPlaylistHandler(dal *dal.DAL) api.Handler { 14 | 15 | return func(params url.Values, context *api.HandlerContext) *api.Response { 16 | 17 | idParam := params.Get("id") 18 | id := util.ParseUint(idParam, 0) 19 | if id == 0 { 20 | message := fmt.Sprintf("Playlist not found: %v", idParam) 21 | return api.NewErrorReponse(dto.Generic, message) 22 | } 23 | 24 | conn, err := dal.Db.GetConn() 25 | if err != nil { 26 | return api.NewErrorReponse(dto.Generic, err.Error()) 27 | } 28 | defer conn.Release() 29 | 30 | p, err := dal.Db.GetPlaylist(conn, id, context.User.Username) 31 | if err != nil { 32 | return api.NewErrorReponse(dto.Generic, err.Error()) 33 | } 34 | 35 | // Pretend that this playlist is owned by the requestor. 36 | // Allows anyone to edit a public playlist (against the intention of 37 | // the Subsonic API). 38 | p.Owner = context.User.Username 39 | 40 | songs, err := dal.Db.GetPlaylistSongs(conn, id, context.User.Username) 41 | if err != nil { 42 | return api.NewErrorReponse(dto.Generic, err.Error()) 43 | } 44 | 45 | return api.NewSuccessfulReponse(dto.NewPlaylist(p, songs)) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /go/subsonic/handler/getPlaylists.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/dal" 7 | "github.com/hednowley/sound/subsonic/api" 8 | "github.com/hednowley/sound/subsonic/dto" 9 | ) 10 | 11 | // NewGetPlaylistsHandler does http://www.subsonic.org/pages/api.jsp#getPlaylists 12 | func NewGetPlaylistsHandler(dal *dal.DAL) api.Handler { 13 | 14 | return func(params url.Values, context *api.HandlerContext) *api.Response { 15 | conn, err := dal.Db.GetConn() 16 | if err != nil { 17 | return api.NewErrorReponse(dto.Generic, err.Error()) 18 | } 19 | defer conn.Release() 20 | 21 | playlists, err := dal.Db.GetPlaylists(conn, context.User.Username) 22 | if err != nil { 23 | return api.NewErrorReponse(dto.Generic, err.Error()) 24 | } 25 | 26 | // Pretend that all accessible playlists are owned by the requestor. 27 | // Allows anyone to edit a public playlist (against the intention of 28 | // the Subsonic API). 29 | for i := range playlists { 30 | playlists[i].Owner = context.User.Username 31 | } 32 | 33 | return api.NewSuccessfulReponse(dto.NewPlaylistCollection(playlists)) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /go/subsonic/handler/getRandomSongs.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/dal" 7 | "github.com/hednowley/sound/subsonic/api" 8 | "github.com/hednowley/sound/subsonic/dto" 9 | "github.com/hednowley/sound/util" 10 | ) 11 | 12 | // NewGetRandomSongsHandler does http://www.subsonic.org/pages/api.jsp#getRandomSongs 13 | func NewGetRandomSongsHandler(dal *dal.DAL) api.Handler { 14 | 15 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 16 | 17 | sizeParam := params.Get("size") 18 | size := util.ParseUint(sizeParam, 10) 19 | 20 | genre := params.Get("genre") 21 | 22 | fromParam := params.Get("fromYear") 23 | from := util.ParseUint(fromParam, 0) 24 | 25 | toParam := params.Get("toYear") 26 | to := util.ParseUint(toParam, 0) 27 | 28 | conn, err := dal.Db.GetConn() 29 | if err != nil { 30 | return api.NewErrorReponse(dto.Generic, err.Error()) 31 | } 32 | defer conn.Release() 33 | 34 | songs, err := dal.Db.GetRandomSongs(conn, size, from, to, genre) 35 | if err != nil { 36 | return api.NewErrorReponse(dto.Generic, err.Error()) 37 | } 38 | 39 | return api.NewSuccessfulReponse(dto.NewRandomSongs(songs)) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /go/subsonic/handler/getSong.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/dal" 7 | "github.com/hednowley/sound/dao" 8 | "github.com/hednowley/sound/subsonic/api" 9 | "github.com/hednowley/sound/subsonic/dto" 10 | "github.com/hednowley/sound/util" 11 | ) 12 | 13 | // NewGetSongHandler does http://www.subsonic.org/pages/api.jsp#getSong 14 | func NewGetSongHandler(dal *dal.DAL) api.Handler { 15 | 16 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 17 | 18 | idParam := params.Get("id") 19 | id := util.ParseUint(idParam, 0) 20 | if id == 0 { 21 | return api.NewErrorReponse(dto.MissingParameter, "Required param (id) is missing") 22 | } 23 | 24 | conn, err := dal.Db.GetConn() 25 | if err != nil { 26 | return api.NewErrorReponse(dto.Generic, err.Error()) 27 | } 28 | defer conn.Release() 29 | 30 | file, err := dal.Db.GetSong(conn, id) 31 | if err != nil { 32 | if dao.IsErrNotFound(err) { 33 | return api.NewErrorReponse(dto.NotFound, "Song not found.") 34 | } 35 | return api.NewErrorReponse(dto.Generic, err.Error()) 36 | } 37 | 38 | return api.NewSuccessfulReponse(dto.NewSong(file)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /go/subsonic/handler/getSongsByGenre.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/dal" 7 | "github.com/hednowley/sound/subsonic/api" 8 | "github.com/hednowley/sound/subsonic/dto" 9 | "github.com/hednowley/sound/util" 10 | ) 11 | 12 | func NewGetSongsByGenreHandler(dal *dal.DAL) api.Handler { 13 | 14 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 15 | 16 | genre := params.Get("genre") 17 | if len(genre) == 0 { 18 | return api.NewErrorReponse(dto.MissingParameter, "Required param (genre) is missing") 19 | } 20 | 21 | countParam := params.Get("count") 22 | count := util.ParseUint(countParam, 10) 23 | 24 | offsetParam := params.Get("offset") 25 | offset := util.ParseUint(offsetParam, 0) 26 | 27 | conn, err := dal.Db.GetConn() 28 | if err != nil { 29 | return api.NewErrorReponse(dto.Generic, err.Error()) 30 | } 31 | defer conn.Release() 32 | 33 | songs, err := dal.Db.GetSongsByGenre(conn, genre, offset, count) 34 | if err != nil { 35 | return api.NewErrorReponse(dto.Generic, err.Error()) 36 | } 37 | 38 | return api.NewSuccessfulReponse(dto.NewSongsByGenre(songs)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /go/subsonic/handler/getUser.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/config" 7 | "github.com/hednowley/sound/subsonic/api" 8 | "github.com/hednowley/sound/subsonic/dto" 9 | ) 10 | 11 | func NewGetUserHandler(config *config.Config) api.Handler { 12 | 13 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 14 | 15 | username := params.Get("username") 16 | if len(username) == 0 { 17 | return api.NewErrorReponse(dto.NotFound, "No username.") 18 | } 19 | 20 | for _, user := range config.Users { 21 | if user.Username == username { 22 | return api.NewSuccessfulReponse(dto.NewUser(user)) 23 | } 24 | } 25 | 26 | return api.NewErrorReponse(dto.NotFound, "User not found.") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /go/subsonic/handler/getUsers.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/config" 7 | "github.com/hednowley/sound/subsonic/api" 8 | "github.com/hednowley/sound/subsonic/dto" 9 | ) 10 | 11 | func NewGetUsersHandler(config *config.Config) api.Handler { 12 | 13 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 14 | return api.NewSuccessfulReponse(dto.NewUserCollection(config.Users)) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /go/subsonic/handler/ping.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/subsonic/api" 7 | ) 8 | 9 | // NewPingHandler is a handler for responding to ping requests. 10 | // It replies to any request with an empty success response. 11 | func NewPingHandler() api.Handler { 12 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 13 | return api.NewEmptyReponse() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /go/subsonic/handler/ping_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/hednowley/sound/subsonic/api" 8 | ) 9 | 10 | func TestPing(t *testing.T) { 11 | 12 | handler := NewPingHandler() 13 | response := handler(url.Values{}, &api.HandlerContext{}) 14 | 15 | if !response.IsSuccess { 16 | t.Error() 17 | } 18 | 19 | if response.Body != nil { 20 | t.Error() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /go/subsonic/handler/scan.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/provider" 7 | "github.com/hednowley/sound/subsonic/api" 8 | "github.com/hednowley/sound/subsonic/dto" 9 | ) 10 | 11 | func NewStartScanHandler(dal *provider.Scanner) api.Handler { 12 | 13 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 14 | go dal.StartAllScans(false, false) 15 | r := dto.NewScanStatus(dal.GetScanStatus(), dal.GetScanFileCount()) 16 | return api.NewSuccessfulReponse(r) 17 | } 18 | } 19 | 20 | func NewGetScanStatusHandler(dal *provider.Scanner) api.Handler { 21 | 22 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 23 | r := dto.NewScanStatus(dal.GetScanStatus(), dal.GetScanFileCount()) 24 | return api.NewSuccessfulReponse(r) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /go/subsonic/handler/star.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/hednowley/sound/dal" 7 | "github.com/hednowley/sound/subsonic/api" 8 | "github.com/hednowley/sound/subsonic/dto" 9 | "github.com/hednowley/sound/util" 10 | ) 11 | 12 | func NewStarHandler(dal *dal.DAL, star bool) api.Handler { 13 | 14 | return func(params url.Values, _ *api.HandlerContext) *api.Response { 15 | 16 | param := params.Get("id") 17 | id := util.ParseUint(param, 0) 18 | if id == 0 { 19 | err := dal.StarSong(id, star) 20 | if err != nil { 21 | return api.NewErrorReponse(dto.Generic, err.Error()) 22 | } 23 | 24 | return api.NewEmptyReponse() 25 | } 26 | 27 | param = params.Get("albumId") 28 | id = util.ParseUint(param, 0) 29 | if id == 0 { 30 | err := dal.StarAlbum(id, star) 31 | if err != nil { 32 | return api.NewErrorReponse(dto.Generic, err.Error()) 33 | } 34 | 35 | return api.NewEmptyReponse() 36 | } 37 | 38 | param = params.Get("artistId") 39 | id = util.ParseUint(param, 0) 40 | if id == 0 { 41 | err := dal.StarArtist(id, star) 42 | if err != nil { 43 | return api.NewErrorReponse(dto.Generic, err.Error()) 44 | } 45 | 46 | return api.NewEmptyReponse() 47 | } 48 | 49 | return api.NewErrorReponse(dto.Generic, "Missing parameter") 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /go/subsonic/handler/updatePlaylist_test.go: -------------------------------------------------------------------------------- 1 | package handler_test 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/hednowley/sound/config" 8 | "github.com/hednowley/sound/dal" 9 | "github.com/hednowley/sound/subsonic/api" 10 | "github.com/hednowley/sound/subsonic/handler" 11 | ) 12 | 13 | func TestUpdatePlaylist(t *testing.T) { 14 | 15 | db := dal.NewMock() 16 | 17 | handler := handler.NewUpdatePlaylistHandler(db) 18 | params := url.Values{} 19 | params.Add("playlistId", "1") 20 | params.Add("name", "new_name") 21 | 22 | context := api.HandlerContext{ 23 | User: &config.User{ 24 | Username: "tommy", 25 | }, 26 | } 27 | 28 | response := handler(params, &context) 29 | 30 | if !response.IsSuccess { 31 | t.Error("Should succeed") 32 | } 33 | 34 | if response.Body != nil { 35 | t.Error("Should have no body") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /go/testdata/beetslib.blb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hednowley/sound/3fa1ee0a0b1e3d15d12557429fa674f9e19ee93d/go/testdata/beetslib.blb -------------------------------------------------------------------------------- /go/testdata/config.yaml: -------------------------------------------------------------------------------- 1 | secret: "changeme" 2 | 3 | port: 3684 4 | 5 | art path: "~/temp/art" 6 | 7 | art sizes: 8 | - 100 9 | - 200 10 | 11 | db: "host=localhost port=5432 user=postgres password=sound dbname=sound sslmode=disable" 12 | 13 | log config: "log-config.xml" 14 | 15 | ignored articles: 16 | - A 17 | - The 18 | - El 19 | - Los 20 | 21 | filesystem: 22 | - path: "~/temp/my music" 23 | name: "Gertrude's bangers" 24 | extensions: 25 | - mp3 26 | - flac 27 | 28 | - path: "~/temp2/my music" 29 | name: "Choons" 30 | extensions: 31 | - docx 32 | 33 | beets: 34 | - database: "~/beets/beetslib.blb" 35 | name: "My beets music" 36 | 37 | users: 38 | - username: gertrude 39 | password: changeme 40 | 41 | access control allow origin: "*" 42 | 43 | websocket ticket expiry: 30 44 | -------------------------------------------------------------------------------- /go/testdata/dao/albums.yml: -------------------------------------------------------------------------------- 1 | - id: 1 2 | artist_id: 1 3 | name: "album_1" 4 | created: 2018-06-12T11:11:11Z 5 | disambiguator: "" 6 | starred: false 7 | 8 | - id: 2 9 | artist_id: 6 10 | name: "album_without_art" 11 | created: 2018-06-12T11:11:11Z 12 | disambiguator: "" 13 | starred: false 14 | 15 | - id: 3 16 | artist_id: 1 17 | name: "album_without_genre" 18 | created: 2018-06-12T11:11:11Z 19 | disambiguator: "" 20 | starred: false 21 | 22 | - id: 4 23 | artist_id: 1 24 | name: "album_without_year" 25 | created: 2018-06-12T11:11:11Z 26 | disambiguator: "" 27 | starred: false 28 | -------------------------------------------------------------------------------- /go/testdata/dao/artists.yml: -------------------------------------------------------------------------------- 1 | - id: 1 2 | name: "artist_1" 3 | starred: false 4 | 5 | - id: 2 6 | name: "artist_2" 7 | starred: false 8 | 9 | - id: 4 10 | name: "artist_4" 11 | starred: false 12 | 13 | - id: 5 14 | name: "beethoven" 15 | starred: false 16 | 17 | - id: 6 18 | name: "artist_without_art" 19 | starred: false 20 | 21 | - id: 7 22 | name: "artist_without_albums" 23 | starred: false 24 | -------------------------------------------------------------------------------- /go/testdata/dao/arts.yml: -------------------------------------------------------------------------------- 1 | - id: 1 2 | hash: "hash_1" 3 | path: "art_1.png" 4 | -------------------------------------------------------------------------------- /go/testdata/dao/genres.yml: -------------------------------------------------------------------------------- 1 | - id: 1 2 | name: "genre_1" 3 | 4 | - id: 2 5 | name: "genre_2" 6 | 7 | - id: 4 8 | name: "genre_4" 9 | -------------------------------------------------------------------------------- /go/testdata/dao/playlist_entries.yml: -------------------------------------------------------------------------------- 1 | - id: 1 2 | playlist_id: 1 3 | song_id: 1 4 | index: 0 5 | 6 | - id: 3 7 | playlist_id: 1 8 | song_id: 1 9 | index: 5 10 | 11 | - id: 4 12 | playlist_id: 1 13 | song_id: 14 14 | index: 2 15 | 16 | - id: 10 17 | playlist_id: 1 18 | song_id: 2 19 | index: 6 20 | -------------------------------------------------------------------------------- /go/testdata/dao/playlists.yml: -------------------------------------------------------------------------------- 1 | - id: 1 2 | name: "playlist_1" 3 | comment: "comment_1" 4 | public: true 5 | comment: "comment_1" 6 | created: 2018-06-12T11:11:11Z 7 | changed: 2018-06-12T11:11:11Z 8 | owner: "tommy" 9 | -------------------------------------------------------------------------------- /go/testdata/music/1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hednowley/sound/3fa1ee0a0b1e3d15d12557429fa674f9e19ee93d/go/testdata/music/1.mp3 -------------------------------------------------------------------------------- /go/testdata/music/2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hednowley/sound/3fa1ee0a0b1e3d15d12557429fa674f9e19ee93d/go/testdata/music/2.mp3 -------------------------------------------------------------------------------- /go/testdata/music/lost.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hednowley/sound/3fa1ee0a0b1e3d15d12557429fa674f9e19ee93d/go/testdata/music/lost.txt -------------------------------------------------------------------------------- /go/testdata/music/subfolder/3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hednowley/sound/3fa1ee0a0b1e3d15d12557429fa674f9e19ee93d/go/testdata/music/subfolder/3.mp3 -------------------------------------------------------------------------------- /go/todo.txt: -------------------------------------------------------------------------------- 1 | - ignored articles 2 | - Get duration and bitrate from files 3 | - transcoding 4 | - scheduling 5 | - https://golang.org/pkg/sync/#Pool 6 | - Deploy to web server 7 | - Make scans stoppable 8 | - Test beets provider 9 | - Disambiguation (find first different fields?) 10 | - Play counts 11 | - Implement getartistinfo2 12 | - SQL injection in search 13 | - Speed up artist first-letter code 14 | - Artists with different capitalisations 15 | - Think about emopty album names, genre etc 16 | - Podcasts 17 | - Add real providers to user folders -------------------------------------------------------------------------------- /go/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | // Min returns the smaller of two uints. 9 | func Min(a int, b int) int { 10 | if a > b { 11 | return b 12 | } 13 | return a 14 | } 15 | 16 | // Max returns the larger of two uints. 17 | func Max(a int, b int) int { 18 | if a < b { 19 | return b 20 | } 21 | return a 22 | } 23 | 24 | // Contains checks whether a slice of uints contains the given value. 25 | func ContainsUint(slice []uint, value uint) bool { 26 | for _, v := range slice { 27 | if v == value { 28 | return true 29 | } 30 | } 31 | return false 32 | } 33 | 34 | func ContainsString(slice []string, value string) bool { 35 | for _, v := range slice { 36 | if v == value { 37 | return true 38 | } 39 | } 40 | return false 41 | } 42 | 43 | func FormatUint(i uint) string { 44 | return strconv.FormatUint(uint64(i), 10) 45 | } 46 | 47 | // ParseUint tries to parse a string into a uint. If this is not possible 48 | // then the given default is returned instead. 49 | func ParseUint(param string, defaultValue uint) uint { 50 | 51 | id, err := strconv.ParseUint(param, 10, 32) 52 | if err != nil { 53 | return defaultValue 54 | } 55 | return uint(id) 56 | } 57 | 58 | // ParseBool tries to parse a string into a bool. If this is not possible 59 | // then a nil pointer is returned instead. 60 | func ParseBool(param string) *bool { 61 | 62 | param = strings.ToLower(param) 63 | if param == "true" || param == "1" { 64 | b := true 65 | return &b 66 | } 67 | if param == "false" || param == "0" { 68 | b := false 69 | return &b 70 | } 71 | 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /go/util/util_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/hednowley/sound/util" 7 | ) 8 | 9 | func TestMin(t *testing.T) { 10 | 11 | m := util.Min(100, 80) 12 | if m != 80 { 13 | t.Error() 14 | } 15 | 16 | m = util.Min(-10, -20) 17 | if m != -20 { 18 | t.Error() 19 | } 20 | 21 | m = util.Min(-5, -5) 22 | if m != -5 { 23 | t.Error() 24 | } 25 | } 26 | 27 | func TestMax(t *testing.T) { 28 | 29 | m := util.Max(100, 80) 30 | if m != 100 { 31 | t.Error() 32 | } 33 | 34 | m = util.Max(-10, -20) 35 | if m != -10 { 36 | t.Error() 37 | } 38 | 39 | m = util.Max(-5, -5) 40 | if m != -5 { 41 | t.Error() 42 | } 43 | } 44 | 45 | func TestContains(t *testing.T) { 46 | 47 | s := []uint{} 48 | if util.ContainsUint(s, 0) { 49 | t.Error() 50 | } 51 | 52 | s = []uint{5, 80} 53 | if util.ContainsUint(s, 10) { 54 | t.Error() 55 | } 56 | if !util.ContainsUint(s, 80) { 57 | t.Error() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /sound.code-workspace: -------------------------------------------------------------------------------- 1 | t{ 2 | "folders": [ 3 | { 4 | "path": "." 5 | }, 6 | { 7 | "path": "go" 8 | }, 9 | { 10 | "path": "elm" 11 | } 12 | ], 13 | "settings": {} 14 | } 15 | --------------------------------------------------------------------------------