Giter Site home page Giter Site logo

sheraff / soft-serve-tunes Goto Github PK

View Code? Open in Web Editor NEW
7.0 2.0 0.0 18.42 MB

Self hosted music server on raspberry pi

JavaScript 0.20% TypeScript 92.78% CSS 7.02%
create-t3-app postgresql prisma progressive-web-app raspberry-pi service-worker trpc typescript

soft-serve-tunes's Introduction

screen-20230115-120914_2_shrink.mp4


If anyone ever uses this repo...

...You should tell me so I can plan accordingly! Because I'm not writing any data migration scripts (for server database, for client indexedDB, for service-worker cache) and I do the changes manually, but that's not very practical for anyone else but me. So if you have the project running and try to update it after some changes, stuff might break.

Commands

command description
npm run db runs necessary prisma scripts after a change in schema
npm run spawn starts a process in pm2 after a build (if a process already exists, you should pm2 delete soft-serve-tunes before)
npm run wake server cold start is quite long (because we're verifying all files in case anything changed while the server was off) ; this command starts the cold-start process without having to open the app

RESOURCES

Deploy to raspberry

update raspbian

access rpi

freebox > box settings > ports >

  • 22 > 22
  • if terminal isn't happy because it's a new unknown source ssh-keygen -R [host]

app

  • install node, upgrade to 18
    sudo su
    curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
    sudo apt install nodejs # or follow CLI instructions
  • install git
    sudo apt install git
  • git clone the project
    git clone https://github.com/Sheraff/soft-serve-tunes.git
  • some binaries need chmoding
    chmod u+rx .src/server/persistent/bin/fpcalc/fpcalc-darwin
    chmod u+rx .src/server/persistent/bin/fpcalc/fpcalc-linux
    also, I could not find a self-contained fpcalc for linux, so we need to install 250Mo of dependencies (which will include fpcalc itself) (see server/bin readme for more information)
    apt-get install libchromaprint-tools
  • install postgresql (https://pimylifeup.com/raspberry-pi-postgresql/)
    sudo apt install postgresql
    createuser pi -P --interactive # will determine the user:password to use in the .env file
    psql
    CREATE DATABASE pi;
    exit
    exit
  • install npm i
  • configure .env music folder
  • build (put pi on a fan, it's gonna heat up) npm run build
  • rm prisma/db.sqlite
  • npm run db
  • npm start

ports

freebox > static local IP for server

  • freebox settings > advanced > DHCP > static
  • assign raspberry pi to static IP configure DynDNS w/ OVH + freebox freebox > box settings > ports >
  • 443 > 3000 (https)
  • 80 > 3000 (http)
  • 3001 > 3001 (ws) ??useless??
  • site should be accessible over HTTP

ssl certificates

  • apt-get install apache2

  • freebox ports should be set to their default value (443 > 443, 80 > 80)

  • install cert-bot by let's encrypt (https://certbot.eff.org/instructions?ws=apache&os=debianbuster)

    sudo apt install snapd
    sudo snap install core; sudo snap refresh core
    sudo snap install --classic certbot
    sudo ln -s /snap/bin/certbot /usr/bin/certbot
    sudo certbot --apache
  • in /etc/apache2/sited-enabled, edit the .conf files (see example below) so that

    • all HTTP traffic is redirected to HTTPS
    • incoming 443 and outgoing 3000 go to the correct destination
    • HTTP upgrade requests go to the correct scheme http>ws and ws>http
  • make sure apache version is >= 2.4 (apache2 -v)

  • enable some apache modules

    a2enmod proxy
    a2enmod proxy_http
    a2enmod proxy_wstunnel
    systemctl restart apache2

enable http2

cp /etc/apache2/mods-available/http2.load /etc/apache2/mods-enabled/http2.load
cp /etc/apache2/mods-available/http2.conf /etc/apache2/mods-enabled/http2.conf
systemctl restart apache2

remove apache headers / server signature

  • edit the security config file
    nano /etc/apache2/conf-enabled/security.conf
  • Replace the 2 following settings:
    ServerTokens Prod # remove `Server` header w/ OS version and apache version
    ServerSignature Off # hide information from server generated pages (e.g. Internal Server Error).
    
  • restart apache
    systemctl restart apache2

process manager

  • install pm2 npm install pm2@latest -g
  • spawn the server w/ npm run spawn or pm2 start npm --time --name soft-serve-tunes -- start
  • subsequent re-start can be done w/ pm2 reload 0 or pm2 reload soft-serve-tunes
  • auto-start pm2 on reboot:
    pm2 startup
    pm2 save

If using wifi

Prevent connection "timeout after idle"

The raspberry pi comes with a power management utility on its wifi chip. This results in connections that are very slow / timeout if the raspberry hasn't connected to the network in a while. This forum post helped.

  • observe the "Power Management" setting w/ iwconfig
    wlan0
        Power Management:on
  • disable power management sudo iwconfig wlan0 power off
  • disable power management permanently:
    • sudo nano /etc/rc.local
    • add iwconfig wlan0 power off to the file

If using ethernet

Disable wifi

Disabling the wifi can boost raspberry performance. This article helped.

sudo nano /boot/config.txt

add dtoverlay=disable-wifi to the config, under [all]

Intranet streaming

If you want to stream directly server-to-device without going through the internet when both are on the same network, you need to setup a few more things:

  • Create a DNS A record that resolves to the static IP assigned to the raspberry pi server
  • Generate a certificate by using a DNS challenge (since the domain cannot be accessed outside of your network, certificate authority can only access the DNS record): https://www.digitalocean.com/community/tutorials/how-to-acquire-a-let-s-encrypt-certificate-using-dns-validation-with-acme-dns-certbot-on-ubuntu-18-04
    wget https://github.com/joohoi/acme-dns-certbot-joohoi/raw/master/acme-dns-auth.py
    chmod +x acme-dns-auth.py
    nano acme-dns-auth.py # change the shebang to use python3 instead of python
    sudo mv acme-dns-auth.py /etc/letsencrypt/ # where letsencrypt can find it
    sudo certbot certonly --manual --manual-auth-hook /etc/letsencrypt/acme-dns-auth.py --preferred-challenges dns --debug-challenges -d local.my-domain.com
    # certbot will prompt you to go and register a CNAME DNS redirection from your domain
    # to theirs, so they can verify that you do own the domain, do that, and check that it
    # is correctly registered with `dig _acme-challenge.local.my-domain.com`
    sudo certbot renew --dry-run # make sure the new cert is included in the auto-renew rotation
  • enable apache proxy with new local host and newly generated certificates (in a .conf file inside /etc/apache2/sites-enabled)
    <VirtualHost *:443>
          ProxyPreserveHost On
          ProxyRequests Off
          # name doesn't matter (unless self-signed certificate wasn't wildcard but on a specific domain)
          ServerName foobar.com
          ServerAlias *
    
          RewriteEngine On
          RewriteCond %{HTTP:Upgrade} =websocket [NC]
          RewriteRule /(.*)           ws://localhost:3001/$1 [P,L]
          RewriteCond %{HTTP:Upgrade} !=websocket [NC]
          RewriteRule /(.*)           http://localhost:3000/$1 [P,L]
    
          ProxyPass / http://localhost:3000/
          ProxyPassReverse / http://localhost:3000/
    
          ErrorLog ${APACHE_LOG_DIR}/error.log
          CustomLog ${APACHE_LOG_DIR}/access.log combined
    
          # self signed
          SSLCertificateFile /etc/letsencrypt/live/local.my-domain.com/fullchain.pem
          SSLCertificateKeyFile /etc/letsencrypt/live/local.my-domain.com/privkey.pem
          Include /etc/letsencrypt/options-ssl-apache.conf
    </VirtualHost>
    
  • not necessary, but you might also want to force http connections to upgrade to https
    # in the *:80 VirtualHost
    RewriteCond %{SERVER_NAME} =local.my-domain.com
    RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
    
  • restart apache
    systemctl restart apache2
  • add the local host to .env file
    NEXT_PUBLIC_INTRANET_HOST=https://local.my-domain.com

example .conf files

/etc/apache2/sites-enabled/000-default.conf

<VirtualHost *:80>
   ErrorLog ${APACHE_LOG_DIR}/error.log
   CustomLog ${APACHE_LOG_DIR}/access.log combined

   # added by certbot
   RewriteEngine on
   RewriteCond %{SERVER_NAME} =my-domain.com
   RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]

   # if we want intranet streaming
   RewriteCond %{SERVER_NAME} =local.my-domain.com
   RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>

/etc/apache2/sites-enabled/000-default-le-ssl.conf (created by certbot)

<IfModule mod_ssl.c>
   <VirtualHost *:443>

      ProxyPreserveHost On
      ProxyRequests Off
      ServerName my-domain.com

      RewriteEngine On
      RewriteCond %{HTTP:Upgrade} =websocket [NC]
      RewriteRule /(.*)           ws://localhost:3001/$1 [P,L]
      RewriteCond %{HTTP:Upgrade} !=websocket [NC]
      RewriteRule /(.*)           http://localhost:3000/$1 [P,L]

      ProxyPass / http://localhost:3000/
      ProxyPassReverse / http://localhost:3000/

      ErrorLog ${APACHE_LOG_DIR}/error.log
      CustomLog ${APACHE_LOG_DIR}/access.log combined

      # certbot
      ServerName my-domain.com
      SSLCertificateFile /etc/letsencrypt/live/my-domain.com/fullchain.pem
      SSLCertificateKeyFile /etc/letsencrypt/live/my-domain.com/privkey.pem
      Include /etc/letsencrypt/options-ssl-apache.conf
   </VirtualHost>

   # if we want intranet streaming
   <VirtualHost *:443>
        ProxyPreserveHost On
        ProxyRequests Off
        # name doesn't matter (unless self-signed certificate wasn't wildcard but on a specific domain)
        ServerName foobar.com
        ServerAlias *

        RewriteEngine On
        RewriteCond %{HTTP:Upgrade} =websocket [NC]
        RewriteRule /(.*)           ws://localhost:3001/$1 [P,L]
        RewriteCond %{HTTP:Upgrade} !=websocket [NC]
        RewriteRule /(.*)           http://localhost:3000/$1 [P,L]

        ProxyPass / http://localhost:3000/
        ProxyPassReverse / http://localhost:3000/

        ErrorLog ${APACHE_LOG_DIR}/error.log
        CustomLog ${APACHE_LOG_DIR}/access.log combined

        # self signed
        SSLCertificateFile /etc/letsencrypt/live/local.my-domain.com/fullchain.pem
        SSLCertificateKeyFile /etc/letsencrypt/live/local.my-domain.com/privkey.pem
        Include /etc/letsencrypt/options-ssl-apache.conf
   </VirtualHost>
</IfModule>

soft-serve-tunes's People

Contributors

sheraff avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

soft-serve-tunes's Issues

Avoid flash of stale content when returning to the suggestion screen

revalidateSwCache(["artist", "mostFav"])
revalidateSwCache(["artist", "mostRecentListen"])
revalidateSwCache(["artist", "leastRecentListen"])
revalidateSwCache(["album", "mostFav"])
revalidateSwCache(["album", "mostRecentListen"])
revalidateSwCache(["album", "mostRecentAdd"])
revalidateSwCache(["genre", "mostFav"])

By listening to sw-trpc-invalidation service worker messages, we could have the revalidateSwCache function resolve its promise only when invalidation is actually done (instead of what it is currently: resolve when its been delegated to the service worker but no request has been made yet), which would allow us to force a refetch of react-query. This should only be an option for revalidateSwCache because we usually don't need that (or maybe we do, check the other places where it's used).

The current situation is that

  • we revalidate the service worker cache (so latest data is available)
  • service worker posts a message to main client, which invalidates the react-query cache
  • when we return to suggestion screen, react query serves up the stale data for a time while re-requesting the data from the service worker

Even with this modification, there will still be a flash of stale content if the data is modified after this phase of revalidations. For example: we're listening to a playlist, so "recently listened artists" changes every song (which is at the top of the suggestions).

Maybe there should be a way to actually subscribe to a query being "always fresh"? Because I believe we're pretty reliable with cache invalidation and with keeping the service worker up to the freshest data (so maybe all that stuff I said above is not actually useful) and the issue if that when the service worker tells the client it has fresh data it only invalidates react-query instead of setting its data.

Is the solution to actually postMessage the data itself from the service worker so it can be passed to setQueryData?

Look into Essentia (or ≠ signal processing / ML models) for music feature extraction without relying on spotify

https://essentia.upf.edu/models.html

Essentia allows us to extract "simple" music features (loudness, bpm, danceability) as well as more complex features through ML (pre-trained?) models (genre, mood, instrumentation, voiciness, valence, gender, acousticness, ...)

5Go / 8M+ sqlite dataset of spotify tracks and their audio features: https://www.kaggle.com/datasets/maltegrosse/8-m-spotify-tracks-genre-audio-features

We don't have to stick to Spotify's feature list (acousticness, danceability, energy, instrumentalness, liveness, speechiness, valence) as long as we find some vectors that allow for

  • playlist creation based on human-understandable descriptions of musical feature space
  • track matching based on similarity of features, that gives satisfyingly "similar" tracks
  • varied enough vectors (i.e. 3 axes would be too little, 7 is more than enough)

Crash when generating too many images

0|soft-serve-tunes  | 2023-01-15T11:39:24: node:internal/process/promises:288
0|soft-serve-tunes  | 2023-01-15T11:39:24:             triggerUncaughtException(err, true /* fromPromise */);
0|soft-serve-tunes  | 2023-01-15T11:39:24:             ^
0|soft-serve-tunes  | 2023-01-15T11:39:24: 
0|soft-serve-tunes  | 2023-01-15T11:39:24: [Error: Input file contains unsupported image format]
0|soft-serve-tunes  | 2023-01-15T11:39:24: 
0|soft-serve-tunes  | 2023-01-15T11:39:24: Node.js v18.12.1
PM2                 | App [soft-serve-tunes:0] exited with code [1] via signal [SIGINT]
PM2                 | App [soft-serve-tunes:0] starting in -fork mode-
PM2                 | App [soft-serve-tunes:0] online
0|soft-serve-tunes  | 2023-01-15T11:39:28: 
0|soft-serve-tunes  | 2023-01-15T11:39:28: > [email protected] start
0|soft-serve-tunes  | 2023-01-15T11:39:28: > NODE_OPTIONS=--max_old_space_size=768 next start
0|soft-serve-tunes  | 2023-01-15T11:39:28: 

The above crash happened twice while simply scrolling the artist library list.

Likely related: lovell/sharp#2003

Intuition is that because of the virtualization of the list, the same image might be requested twice in a short time, with the 2nd time happening before the 1st even finished. Which causes a race condition where either

  • we're trying to serve an image currently being written,
  • or we're trying to write to the same image twice at the same time

We should either setup a queue for sharp (though that might be very limiting as it is able to process multiple images at the same time), or we should setup a "currently running" set of promises.

!!! Images that are served broken (like from this error) are still cached by the browser because headers are sent before streaming begins !!!

Service Worker: 200 cached responses don't allow seeking on audio element

When an audio file was served from the SW cache, seeking a currentTime on the audio node doesn't work. Either by clicking on the input type="range" that indicates audio progress, or by clicking on the "previous" button to go back to the start of the track.

EDIT: it actually works when clicking on "previous", but that might be because clicking anywhere on the input type="range" also brings the currentTime back to 0.

Shuffle on not-currently-playing playlist: shuffle current too

Currently, when the player is paused (or not yet started), the shuffle is off and we select a playlist, it automatically sets the current track as the first one. When we turn shuffle on it suffles the playlist but not the current. So there is no way to "start as shuffled", we always start at the first track.

A better behavior would be that if we turn shuffle on for a playlist that hasn't yet started, the current track is selected randomly (basically select the first in shuffled order after shuffling the order).

To keep in mind:

  • if the playlist is currently playing, keep current behavior
  • if the playlist was started but got paused, maybe keep current behavior?

Investigate file system API

Maybe media files (+ images) might be more reliably stored in the navigator's private file system https://web.dev/file-system-access/

const root = await navigator.storage.getDirectory();
// Create a new file handle.
const fileHandle = await root.getFileHandle('Untitled.txt', { create: true });
// Create a new directory handle.
const dirHandle = await root.getDirectoryHandle('New Folder', { create: true });
// Recursively remove a directory.
await root.removeEntry('Old Stuff', { recursive: true });

I haven't had any problems so far with the service worker cache. This is just in case.

Shuffle & Add To Playlist: reshuffle "tracks that have not yet played" except "play next" stack

Currently, when adding a track to the currently playing playlist with shuffle on, we re-shuffle the entire playlist (with current track set as the first).

A better / more "expected" behavior would be

  • leave already played tracks as is in the order (up to and including cache.current shouldn't change)
  • leave "string of uninterrupted tracks that are in the play next stack" as is in the order
  • shuffle the rest

edit view

  • track edit
    • track feats edit
    • track genre edit
  • album edit
  • artist edit
  • genre edit
  • playlist edit

Infinite playlists

Similar to a Spotify "radio" or a Youtube "mix", we could have "infinite playlists" that automatically remove played tracks and add new ones.

Would be nice for:

  • full library shuffle
  • liked tracks
  • not-listened-to-in-a-while tracks
  • new tracks
  • ...

We could

  • auto remove tracks that are N-3 before the current (to still allow for "back" button)
  • auto add tracks when playlist is below a certain number
  • keep a list of "already added tracks" so that the query to add more doesn't repeat tracks

If we get this working, we could also implement a "find a mood" function:

shuffles through 10-second previews of seldom-played tracks in your library continuously, until you decide on what to listen to next

display durations

Show

  • track duration on TrackList
  • album duration on AlbumList, AlbumView
  • playlist duration on PlaylistList, NowPlaying

Service Worker: 206 cache responses don't work IFF some of the 206 were from network

SW is able to respond to range: bytes= requests correctly, but if the first responses were from network calls and SW took over midway through, then audio stops playing.

To reproduce, start a (long enough to not be requested instantly) track, cut the network, and wait for the next range: bytes= request. The request itself should be (or seems to be) fine, but the audio stops nonetheless.

Spotify & audioDB results should be checked

Lastfm & acoustid results are validated by a string distance algorithm similarStrings to increase the certainty that they match the requested track.

  • The same should be done for spotify track searches.
  • The same should be done for audiodb searches that are not based on MBID

Where a better "result selection" is needed, there are // TODO: use string distance comments. Though, the proper way of selecting a result might not simply be string distance but something more along the lines of what is done w/ acoustid results.

Potentially:

  • Select lastfm result w/ better algorithm (similar to what is done w/ acoustid results), marked with // TODO: can we do better

switch slow queries to raw SQL

measure first, and then maybe work on:

  • genre (all queries) => measured improvement x20 for list queries, x2 for individual queries
  • artist.get => already 70ms on average (w/o even excluding the few outliers that went really slow on my old computer)
  • cover.fromTracks, cover.fromAlbums => these are so rarely used it doesn't feel worth it
  • getSpotifyTracksByMultiTraitsWithTarget (already SQL, but now that I'm better at it it might be worth taking a second crack at it) => measured improvement x1.15
  • album.byMultiTrait (same as above)

UI tasks

  • audio player
  • search
  • artist view
    • show tracks in addition to albums (or at least, tracks that aren't in any album)
  • album view
  • genre view
    • to have interesting stuff to show on a "genre view" we'd need better data on relationships between genres
  • currently playing
  • playlist view
  • home / recently played / most played
  • #70
  • library view
    • artists
    • albums
    • playlists
    • genres
    • tracks

Better database cleanup

the logic from src/server/persistent/watcher.ts in cleanup is not enough... database can become polluted after a while (if there are a lot of remove / add)

handle SIGINT / SIGTERM to store long-running tasks

to verify: I think some tasks might be non-recoverable if interrupted. For example, fetching a track on Spotify is supposed to then fetch the album and artist and won't ever do so if interrupted after the track.

If there are cases like this, we should listen for sigint or other termination signals and store some recoverable state in database before exiting.

auto-update some computed fields in DB

For example, for the "likes counter" that are owned as a boolean by Track, but also incremented as a number for Album and Artist

  • we could look into postgre trigger to automate this increment
  • we could look into prisma "middleware"

If that works fine, we could also look into have similar fields on Genre, to avoid the complicated requests we have to make at query time

  • likes
  • listens
  • has tracks (in itself or its children)

Better split / compartmentalization of data sources as "plugins"

All third party services should be isolated into "plugins" with an agnostic interface allowing for easy addition / removal of services

  • musicbrainz / acoustid
  • spotify
  • audiodb
  • lastfm

with exposed methods for

  • identifying track from music file
  • obtaining information on an entity (track / album / artist / genre)
  • downloading images for an entity
  • prioritizing an information source for each piece of information (like "for 'band creation year', prefer 'audiodb' as the source")

Routing / Global state

Requirements:

  • panels should have access to their "latest content" even when closed
  • audio tag should have access to the "next track" at all times
  • back button should work in any direction, not just "close current panel"
  • playlist generation ≠ locally modified playlist (play next, re-ordering)
  • locally modified playlist state should be serializable (to restore next time app is opened)

Feature: extend playlist

From within an auto-generated playlist based on traits (byTrait generation), add ability to "load more tracks" at the bottom of the playlist, which would make the same playlist request with a cursor to skip the first (already in playlist) ones.

Edit: missing features

Missing features that might break stuff

  • track: handle multi-artist albums when creating one during track edition
    "16 pièces" by Hocus Pocus might be a good album to try this on: auto metadata only created 2 albums (1 by Hocus Pocus, 1 by multi-artist / undefined)
  • artist: rename
    "Hiromi" might be a good artist to try this on: auto metadata finds her Japanese name, we'd like it to be in english (we should work on "renaming artist", but we also might want to look into collected metadata to see if there isn't a "other names" field somewhere)

Missing track edit features that we can be chill about

  • selecting a cover will lock it, add checkbox to unlock it
  • add possibility to select multiple artists (feats)
  • handle multi-artist albums when assigning tracks to existing album (turn single-artist album into multi-artist)? This might not be useful very often as automatic metadata is usually pretty good and manual edition is just for minor hiccups.
  • add / remove genres (creation possible)

Writing one-off prisma scripts

Look into how to execute one-off scripts to modify the database. I'm not using migrations, but I do sometimes need to modify some stuff.

trpc 10.7.0 `getQueryKey` => SW & agnostic cache invalidation

Every time we need agnostic access to the cache (mainly for cache invalidation on messages from service worker), we should use getQueryKey instead of assuming we know the shape of the key.

Right now, every version upgrade of tRPC needs to be thoroughly checked because a change in the underlying react-query cache keys and/or request param syntax might not be marked as "breaking" (since most users probably don't rely on them). Having a less leaky abstraction might make the whole thing more robust.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.