Browse Source

feat: Initial commit

Matthias Ladkau 4 years ago
commit
33620bdffd

+ 9 - 0
.gitignore

@@ -0,0 +1,9 @@
+.cache
+.cover
+coverage.txt
+coverage.out
+coverage.html
+test
+/dist
+build
+dudeldu

+ 33 - 0
.goreleaser.yml

@@ -0,0 +1,33 @@
+# This is an example goreleaser.yaml file with some sane defaults.
+# Make sure to check the documentation at http://goreleaser.com
+before:
+  hooks:
+    - go mod download
+builds:
+- main: ./server/dudeldu.go
+  env:
+  - CGO_ENABLED=0
+  goos:
+    - windows
+    - linux
+  goarch:
+    - amd64
+checksum:
+  name_template: 'checksums.txt'
+archives:
+  - files:
+    - LICENSE
+    - NOTICE
+    - examples/**/*
+snapshot:
+  name_template: "{{ .Tag }}"
+changelog:
+  sort: asc
+  filters:
+    exclude:
+    - '^docs:'
+    - '^test:'
+
+# Run with:
+# mkdir .cache
+# docker run --rm --user $(id -u):$(id -g) -v $PWD/.cache:/.cache -v $PWD:/go/code -w /go/code goreleaser/goreleaser --snapshot --skip-publish --rm-dist

+ 42 - 0
Dockerfile

@@ -0,0 +1,42 @@
+# Start from the latest alpine based golang base image
+FROM golang:alpine as builder
+
+# Install git
+RUN apk update && apk add --no-cache git
+
+# Add maintainer info
+LABEL maintainer="Matthias Ladkau <matthias@ladkau.de>"
+
+# Set the current working directory inside the container
+WORKDIR /app
+
+# Copy go mod and sum files
+COPY go.mod go.sum ./
+
+# Download all dependencies
+RUN go mod download
+
+# Copy the source from the current directory to the working directory inside the container
+COPY . .
+
+# Build rufs and link statically (no CGO)
+# Use ldflags -w -s to omit the symbol table, debug information and the DWARF table
+RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags="-w -s" ./server/dudeldu.go
+
+# Start again from scratch
+FROM scratch
+
+# Copy the rufs binary
+COPY --from=builder /app/dudeldu /dudeldu
+
+# Set the working directory to data so all created files (e.g. rufs.config.json)
+# can be mapped to physical files on disk
+WORKDIR /data
+
+# Run eliasdb binary
+ENTRYPOINT ["../dudeldu"]
+
+# To run the dudeldu as the current user, expose port 9091 and map
+# all files in the current directory run:
+#
+# docker run --rm --user $(id -u):$(id -g) -v $PWD:/data -p 9091:9091 krotik/dudeldu -host 0.0.0.0 <playlist>

+ 159 - 0
Jenkinsfile

@@ -0,0 +1,159 @@
+pipeline {
+    agent any
+
+    /**
+     * Build file for DudelDu
+     *
+     * Each build happens with 2 commits. The first commit is the actual
+     * feature or fix commit. The commit message should follow conventional
+     * commit messages (https://www.conventionalcommits.org/en/v1.0.0-beta.4/).
+     * In a second commit a program called standard version
+     * (https://github.com/conventional-changelog/standard-version) calculates
+     * a new product version. The versioning will be according to the rules
+     * of “Semantic Versioning” (https://semver.org/).
+     *
+     * Building is done using goreleaser (https://goreleaser.com/) for different
+     * platforms.
+     *
+     * Testing produces code coverage badges which can be embedded on other
+     * pages.
+     *
+     * Everything runs in docker containers to ensure isolation of the build
+     * system and to allow painless upgrades.
+     */
+
+    stages {
+        stage('Commit Analysis') {
+            steps {
+
+                // Read the commit message into a variable
+                //
+                script {
+                  commit_msg = sh(returnStdout: true, script: 'git log -1')
+                }
+            }
+        }
+        stage('Prepare Release Build') {
+
+            // Check for a release build (a commit by standard-version)
+            //
+            when { expression { return commit_msg =~ /chore\(release\)\:/ } }
+            steps {
+
+                // Find out the tagged version
+                //
+                script {
+                  version = sh(returnStdout: true, script: 'git log -1 | grep chore | tr -d "\\n" | sed "s/.*chore(release): \\([0-9\\.]*\\)/\\1/"')
+                }
+
+                echo "Building version: ${version} ..."
+            }
+        }
+        stage('Build') {
+            when { expression { return commit_msg =~ /chore\(release\)\:/ } }
+            steps {
+
+                // Fetch all git tags and run goreleaser
+                //
+                checkout scm
+                sshagent (credentials: ['Gogs']) {
+                    sh 'git fetch --tags'
+                }
+
+                sh 'mkdir -p .cache'
+                sh 'docker run --rm --user $(id -u):$(id -g) -v $PWD/.cache:/.cache -v $PWD:/go/code -w /go/code goreleaser/goreleaser --snapshot --skip-publish --rm-dist'
+            }
+        }
+        stage('Test') {
+
+            // The tests are run in both stages - no release commit is made if the tests fail.
+            // The output is the coverage data and the badge.
+            //
+            steps {
+                echo 'Running tests ...'
+
+                sh """echo '<svg width="88" height="20" xmlns="http://www.w3.org/2000/svg"><g shape-rendering="crispEdges"><path fill="#555" d="M0 0h41v20H0z"/><path fill="#fc1" d="M41 0h40v20H41z"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="20.5" y="14">tests</text><text x="60" y="14">fail</text></g></svg>' > test_result.svg"""
+
+                sh 'docker run --rm -e GOPATH=/tmp -v $PWD:/go golang go test -p 1 --coverprofile=coverage.out ./...'
+                sh 'docker run --rm -e GOPATH=/tmp -v $PWD:/go golang go tool cover --html=coverage.out -o coverage.html'
+
+                echo 'Determine overall coverage and writing badge'
+                script {
+                  coverage = sh(returnStdout: true, script: 'docker run --rm -e GOPATH=/tmp -v $PWD:/go golang go tool cover -func=coverage.out | tee coverage.txt | tail -1 | grep -o "[0-9]*.[0-9]*%$" | tr -d "\\n"')
+
+                  echo "Overall coverage is: ${coverage}"
+
+                  if (coverage.equals("100.0%")) {
+                    sh """echo '<svg width="110" height="20" xmlns="http://www.w3.org/2000/svg"><g shape-rendering="crispEdges"><path fill="#555" d="M0 0h61v20H0z"/><path fill="#4c1" d="M61 0h50v20H61z"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="30.5" y="14">coverage</text><text x="85" y="14">$coverage</text></g></svg>' > test_result.svg"""
+                  } else {
+                    sh """echo '<svg width="110" height="20" xmlns="http://www.w3.org/2000/svg"><g shape-rendering="crispEdges"><path fill="#555" d="M0 0h61v20H0z"/><path fill="#fc1" d="M61 0h50v20H61z"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"><text x="30.5" y="14">coverage</text><text x="85" y="14">$coverage</text></g></svg>' > test_result.svg"""
+                  }
+                }
+            }
+        }
+        stage('Create Release Build Commit') {
+
+            // Check for a non-release build to avoid a commit loop
+            //
+            when { not { expression { return commit_msg =~ /chore\(release\)\:/ } } }
+            steps {
+
+                // Before running standard-version it is important to fetch
+                // the existing tags so next version can be calculated
+                //
+                echo 'Running standard version ...'
+                sshagent (credentials: ['Gogs']) {
+                    sh 'git fetch --tags'
+                }
+                sh 'docker run --rm -v $PWD:/app standard-version'
+
+                // The new version is inserted into the code
+                //
+                script {
+                  new_version = sh(returnStdout: true, script: 'git tag | tail -1 | tr -d "\\n"')
+                }
+                echo "Inserting version $new_version into the code"
+                sh "find . -name '*.go' -exec sed -i -e 's/ProductVersion\\ =\\ \\\".*\\\"/ProductVersion = \\\"${new_version.substring(1)}\\\"/g' {} \\;"
+
+                // The commit is amended to include the code change
+                //
+                echo "Tagging the build and push the changes into the origin repository"
+                sshagent (credentials: ['Gogs']) {
+                    sh 'git config user.name "Matthias Ladkau"'
+                    sh 'git config user.email "webmaster@devt.de"'
+
+                    sh 'git commit -a --amend --no-edit'
+                    sh "git tag --force $new_version"
+
+                    sh 'git push --tags origin master'
+                }
+            }
+        }
+        stage('Upload Release Build Commit') {
+            when { expression { return commit_msg =~ /chore\(release\)\:/ } }
+            steps {
+                echo "Uploading release build ..."
+
+                // After a successful build the resulting artifacts are
+                // uploaded for publication
+                //
+                sshagent (credentials: ['Gogs']) {
+
+                  // Clear distribution folder
+                  sh 'ssh -o StrictHostKeyChecking=no -p 7000 krotik@devt.de rm -fR pub/dudeldu'
+                  sh 'ssh -o StrictHostKeyChecking=no -p 7000 krotik@devt.de mkdir -p pub/dudeldu'
+
+                  // Copy distribution packages in place
+                  sh 'scp -P 7000 -o StrictHostKeyChecking=no dist/*.tar.gz krotik@devt.de:~/pub/dudeldu'
+                  sh 'scp -P 7000 -o StrictHostKeyChecking=no dist/checksums.txt krotik@devt.de:~/pub/dudeldu'
+
+                  // Copy coverage in place
+                  sh 'scp -P 7000 -o StrictHostKeyChecking=no coverage.* krotik@devt.de:~/pub/dudeldu'
+
+                  // Copy test result in place
+                  sh 'scp -P 7000 -o StrictHostKeyChecking=no test_result.svg krotik@devt.de:~/pub/dudeldu'
+                }
+            }
+        }
+    }
+}

+ 7 - 0
LICENSE

@@ -0,0 +1,7 @@
+Copyright 2016 Matthias Ladkau
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 106 - 0
README.md

@@ -0,0 +1,106 @@
+DudelDu
+=======
+DudelDu is a simple audio/video streaming server using the SHOUTcast protocol.
+
+<p>
+<a href="https://void.devt.de/pub/dudeldu/coverage.txt"><img src="https://void.devt.de/pub/dudeldu/test_result.svg" alt="Code coverage"></a>
+<a href="https://goreportcard.com/report/devt.de/krotik/dudeldu">
+<img src="https://goreportcard.com/badge/devt.de/krotik/dudeldu?style=flat-square" alt="Go Report Card"></a>
+<a href="https://godoc.org/devt.de/krotik/dudeldu">
+<img src="https://godoc.org/devt.de/krotik/dudeldu?status.svg" alt="Go Doc"></a>
+</p>
+
+Features
+--------
+- Supports various streaming clients: <a href="http://www.videolan.org/vlc/download-windows.en_GB.html">VLC</a>, <a href="https://play.google.com/store/apps/details?id=net.sourceforge.servestream">ServeStream</a>,  ... and most Icecast clients.
+- Supports sending of meta data (sending artist and title to the streaming client).
+- Playlists are simple JSON files and data files are normal media (e.g. `.mp3`, `.nsv`) files on disk.
+- Can be used as a stand-alone server or embedded in other Go projects.
+- Supports HTTP basic user authentication.
+
+Getting Started (standalone application)
+----------------------------------------
+You can download a pre-compiled package for Windows (win64) or Linux (amd64) [here](https://void.devt.de/pub/dudeldu).
+
+You can also pull the latest docker image of DudelDu from [Dockerhub](https://hub.docker.com/r/krotik/dudeldu):
+```
+docker pull krotik/dudeldu
+```
+
+Create an empty directory, change into it and run the following to start DudelDu:
+```
+docker run --rm --user $(id -u):$(id -g) -v $PWD:/data -p 9091:9091 krotik/dudeldu -host 0.0.0.0 <playlist>
+```
+The container will have access to the current local directory and all subfolders.
+
+### Demo
+
+DudelDu comes with a demo playlist. After extracting DudelDu switch to the directory `examples/demo`. Run ./run_demo.sh (Linux) or run_demo.bat (Windows) to start the server.
+
+Open a browser and view the `demo.html` in the `examples/demo` directory.
+
+You can also point your favourite audio streaming client (e.g. VLC) to the streaming URL:
+```
+http://localhost:9091/bach/cello_suite1
+```
+The demo includes also a small video in the [Nullsoft Streaming Video](https://en.wikipedia.org/wiki/Nullsoft_Streaming_Video) format (NSV). To see it point a video streaming client (e.g. VLC) to:
+```
+http://localhost:9091/trailer/big_buck_bunny
+```
+Note: By default you can only reach the streams via localhost. Use the -host parameter with a host name or IP address to expose it to external network peers.
+
+### Command line options
+The main DudelDu executable has the following command line options:
+```
+DudelDu 0.0.0
+Usage of ./dudeldu [options] <playlist>
+  -?	Show this help message
+  -auth string
+    	Authentication as <user>:<pass>
+  -debug
+    	Enable extra debugging output
+  -fqs int
+    	Frame queue size (default 10000)
+  -host string
+    	Server hostname to listen on (default "localhost")
+  -loop
+    	Loop playlists
+  -port string
+    	Server port to listen on (default "9091")
+  -shuffle
+    	Shuffle playlists
+  -tps int
+    	Thread pool size (default 10)
+```
+
+Building DudelDu
+----------------
+To build DudelDu from source you need to have Go installed (go >= 1.12):
+
+Create a directory, change into it and run:
+```
+git clone https://devt.de/krotik/dudeldu/ .
+```
+
+You can build DudelDu's executable with:
+```
+go build ./server/dudeldu.go
+```
+
+Building DudelDu as Docker image
+--------------------------------
+DudelDu can be build as a secure and compact Docker image.
+
+- Create a directory, change into it and run:
+```
+git clone https://devt.de/krotik/dudeldu/ .
+```
+
+- You can now build the Docker image with:
+```
+docker build --tag krotik/dudeldu .
+```
+
+License
+-------
+DudelDu source code is available under the [MIT License](/LICENSE).

BIN
examples/demo/audio/bach_cello_suite1_menuet1.mp3


BIN
examples/demo/audio/bach_cello_suite1_menuet2.mp3


BIN
examples/demo/audio/bach_cello_suite1_prelude.mp3


+ 29 - 0
examples/demo/demo.html

@@ -0,0 +1,29 @@
+<html>
+  <head>
+    <title>DudelDu Demo</title>
+  </head>
+  <body>
+      <h1>Audio Demo</h1>
+      <p>Listen to Bach's cello suite as an audio stream.</p>
+      <audio controls>
+        <source src="http://localhost:9091/bach/cello_suite1"></source>
+            Your browser does not support the audio tag.
+      </audio>
+      <p>You can also point a streaming client like 
+      <a href="https://www.videolan.org/vlc">VLC</a> to: 
+      <pre>http://localhost:9091/bach/cello_suite1</pre>
+      
+      <h1>Video Demo</h1>
+      <p>View the Big Buck Bunny trailer as MP4 stream in the browser.</p>
+      <video height="300" controls>
+        <source src="http://localhost:9091/trailer/big_buck_bunny_mp4"></source>
+            Your browser does not support the audio tag.
+      </video>
+      <p>You can also point a streaming client like 
+      <a href="https://www.videolan.org/vlc">VLC</a> to: 
+      <pre>http://localhost:9091/trailer/big_buck_bunny</pre>
+      <p>The format here is 
+      <a href="https://en.wikipedia.org/wiki/Nullsoft_Streaming_Video">
+      NSV (Nullsoft Streaming Video)</a>.
+  </body>
+</html>

+ 53 - 0
examples/demo/demo_playlist.dpl

@@ -0,0 +1,53 @@
+/*
+Demo playlist
+
+Point your browser or streaming client to:
+
+http://<host>:<port>/bach/cello_suite1
+
+From Bach's Cello Suite No.1 in G major
+
+Prelude
+Menuet I
+Menuet II
+
+Performed by Colin Carr
+
+License: Public Domain
+
+https://musopen.org/music/2386/johann-sebastian-bach/cello-suite-no-1-in-g-bwv-1007/
+*/
+{
+	"/bach/cello_suite1" : [
+		{
+			"artist" : "Johann Sebastian Bach",
+			"title"  : "Prelude",
+			"path"   : "audio/bach_cello_suite1_prelude.mp3"
+		},
+		{
+			"artist" : "Johann Sebastian Bach",
+			"title"  : "Menuet I",
+			"path"   : "audio/bach_cello_suite1_menuet1.mp3"
+		},
+		{
+			"artist" : "Johann Sebastian Bach",
+			"title"  : "Menuet II",
+			"path"   : "audio/bach_cello_suite1_menuet2.mp3"
+		}
+	],
+	
+	"/trailer/big_buck_bunny" : [
+		{
+			"artist" : "",
+			"title"  : "Big Buck Bunny",
+			"path"   : "movie/bunny.nsv"
+		}
+	],
+	"/trailer/big_buck_bunny_mp4" : [
+		{
+			"artist" : "",
+			"title"  : "Big Buck Bunny",
+			"path"   : "movie/bunny.mp4"
+		}
+	]	
+}

BIN
examples/demo/movie/bunny.mp4


BIN
examples/demo/movie/bunny.nsv


+ 2 - 0
examples/demo/run_demo.bat

@@ -0,0 +1,2 @@
+@echo off
+..\..\dudeldu.exe -loop -shuffle -debug -auth web:web demo_playlist.dpl

+ 2 - 0
examples/demo/run_demo.sh

@@ -0,0 +1,2 @@
+#!/bin/bash
+../../dudeldu -loop -shuffle -debug -auth web:web demo_playlist.dpl

+ 5 - 0
go.mod

@@ -0,0 +1,5 @@
+module devt.de/krotik/dudeldu
+
+go 1.12
+
+require devt.de/krotik/common v1.0.0

+ 2 - 0
go.sum

@@ -0,0 +1,2 @@
+devt.de/krotik/common v1.0.0 h1:nMmFFkjqb8C/oFVfsEi39qnCUbu3J1FXg+FZn5gSOQU=
+devt.de/krotik/common v1.0.0/go.mod h1:X4nsS85DAxyHkwSg/Tc6+XC2zfmGeaVz+37F61+eSaI=

+ 123 - 0
playlist.go

@@ -0,0 +1,123 @@
+/*
+ * DudelDu
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the MIT
+ * License, If a copy of the MIT License was not distributed with this
+ * file, You can obtain one at https://opensource.org/licenses/MIT.
+ */
+
+/*
+Package dudeldu is a simple audio streaming server using the SHOUTcast protocol.
+
+Server
+
+Server is the main server object which runs a shoutcast server instance.
+
+Using a WaitGroup a client can wait for the start and shutdown of the server.
+Incoming new connections are served with a ConnectionHandler method. The
+default implementation for this is the HandleRequest method of the
+DefaultRequestHandler object.
+
+DefaultRequestHandler
+
+DefaultRequestHandler is the default request handler implementation for the
+DudelDu server. DefaultRequestHandler has a customizable ServeRequest function.
+ServeRequest is called once a request was successfully decoded.
+
+The default implementation supports sending meta data while streaming audio. The
+metadata implementation is according to:
+
+http://www.smackfu.com/stuff/programming/shoutcast.html
+
+Playlists
+
+Playlists provide the data which is send to the client. A simple implementation
+will just read .mp3 files and send them in chunks (via the Frame() method) to
+the client.
+
+A request handler uses a PlaylistFactory to produce a Playlist for each new
+connection.
+*/
+package dudeldu
+
+import "errors"
+
+/*
+FrameSize is the suggested size of a frame which should be send to the client
+at a time.
+
+The absolute theoretical maximum frame size for a MPEG audio is 2881 bytes:
+
+MPEG 2.5 Layer II, 8000 Hz @ 160 kbps, with a padding slot.
+Theoretical frame sizes for Layer III range from 24 to 1441 bytes
+there is a "soft" limit imposed by the standard of 960 bytes.
+
+see: http://www.mars.org/pipermail/mad-dev/2002-January/000425.html
+*/
+const FrameSize = 3000
+
+/*
+ErrPlaylistEnd is a special error code which signals that the end of the playlist has been reached
+*/
+var ErrPlaylistEnd = errors.New("End of playlist")
+
+/*
+Playlist is an object which provides a request handler with a
+constant stream of bytes and meta information about the current playing title.
+*/
+type Playlist interface {
+
+	/*
+	   Name is the name of the playlist.
+	*/
+	Name() string
+
+	/*
+	   ContentType returns the content type of this playlist e.g. audio/mpeg.
+	*/
+	ContentType() string
+
+	/*
+	   Artist returns the artist which is currently playing.
+	*/
+	Artist() string
+
+	/*
+	   Title returns the title which is currently playing.
+	*/
+	Title() string
+
+	/*
+		Frame returns the current audio frame which is playing.
+	*/
+	Frame() ([]byte, error)
+
+	/*
+		ReleaseFrame releases a frame which has been written to the client.
+	*/
+	ReleaseFrame([]byte)
+
+	/*
+		Finished returns if the playlist has finished playing.
+	*/
+	Finished() bool
+
+	/*
+		Close any open files by this playlist and reset the current pointer. After this
+		call the playlist can be played again unless an error is returned.
+	*/
+	Close() error
+}
+
+/*
+PlaylistFactory produces a Playlist for a given path.
+*/
+type PlaylistFactory interface {
+
+	/*
+		Playlist returns a playlist for a given path.
+	*/
+	Playlist(path string, shuffle bool) Playlist
+}

+ 331 - 0
playlist/fileplaylist.go

@@ -0,0 +1,331 @@
+/*
+ * DudelDu
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the MIT
+ * License, If a copy of the MIT License was not distributed with this
+ * file, You can obtain one at https://opensource.org/licenses/MIT.
+ */
+
+/*
+Package playlist contains the default playlist implementation.
+
+FilePlaylistFactory
+
+FilePlaylistFactory is a PlaylistFactory which reads its definition from
+a file. The definition file is expected to be a JSON encoded datastructure of the form:
+
+	{
+	    <web path> : [
+	        {
+	            "artist" : <artist>
+	            "title"  : <title>
+	            "path"   : <file path>
+	        }
+	    ]
+	}
+
+The web path is the absolute path which may be requested by the streaming
+client (e.g. /foo/bar would be http://myserver:1234/foo/bar).
+The file path is a physical file reachable by the server process. The file
+ending determines the content type which is send to the client.
+*/
+package playlist
+
+import (
+	"encoding/json"
+	"io/ioutil"
+	"math/rand"
+	"os"
+	"path/filepath"
+	"sync"
+	"time"
+
+	"devt.de/krotik/common/stringutil"
+	"devt.de/krotik/dudeldu"
+)
+
+/*
+FileExtContentTypes maps file extensions to content types
+*/
+var FileExtContentTypes = map[string]string{
+	".mp3":  "audio/mpeg",
+	".flac": "audio/flac",
+	".aac":  "audio/x-aac",
+	".mp4a": "audio/mp4",
+	".mp4":  "video/mp4",
+	".nsv":  "video/nsv",
+	".ogg":  "audio/ogg",
+	".spx":  "audio/ogg",
+	".opus": "audio/ogg",
+	".oga":  "audio/ogg",
+	".ogv":  "video/ogg",
+	".weba": "audio/webm",
+	".webm": "video/webm",
+	".axa":  "audio/annodex",
+	".axv":  "video/annodex",
+}
+
+/*
+FrameSize is the frame size which is used by the playlists
+*/
+var FrameSize = dudeldu.FrameSize
+
+/*
+FilePlaylistFactory data structure
+*/
+type FilePlaylistFactory struct {
+	data map[string][]map[string]string
+}
+
+/*
+NewFilePlaylistFactory creates a new FilePlaylistFactory from a given definition
+file.
+*/
+func NewFilePlaylistFactory(path string) (*FilePlaylistFactory, error) {
+
+	// Try to read the playlist file
+
+	pl, err := ioutil.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+
+	// Strip out comments
+
+	pl = stringutil.StripCStyleComments(pl)
+
+	// Unmarshal json
+
+	ret := &FilePlaylistFactory{}
+
+	err = json.Unmarshal(pl, &ret.data)
+	if err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}
+
+/*
+Playlist returns a playlist for a given path.
+*/
+func (fp *FilePlaylistFactory) Playlist(path string, shuffle bool) dudeldu.Playlist {
+	if data, ok := fp.data[path]; ok {
+
+		// Check if the playlist should be shuffled
+
+		if shuffle {
+			r := rand.New(rand.NewSource(time.Now().UnixNano()))
+
+			shuffledData := make([]map[string]string, len(data), len(data))
+
+			for i, j := range r.Perm(len(data)) {
+				shuffledData[i] = data[j]
+			}
+
+			data = shuffledData
+		}
+
+		return &FilePlaylist{path, 0, data, nil, false,
+			&sync.Pool{New: func() interface{} { return make([]byte, FrameSize, FrameSize) }}}
+	}
+	return nil
+}
+
+/*
+FilePlaylist data structure
+*/
+type FilePlaylist struct {
+	path      string              // Path of this playlist
+	current   int                 // Pointer to the current playing item
+	data      []map[string]string // Playlist items
+	file      *os.File            // Current open file
+	finished  bool                // Flag if this playlist has finished
+	framePool *sync.Pool          // Pool for byte arrays
+}
+
+/*
+currentItem returns the current playlist item
+*/
+func (fp *FilePlaylist) currentItem() map[string]string {
+	if fp.current < len(fp.data) {
+		return fp.data[fp.current]
+	}
+
+	return fp.data[len(fp.data)-1]
+}
+
+/*
+Name is the name of the playlist.
+*/
+func (fp *FilePlaylist) Name() string {
+	return fp.path
+}
+
+/*
+ContentType returns the content type of this playlist e.g. audio/mpeg.
+*/
+func (fp *FilePlaylist) ContentType() string {
+	ext := filepath.Ext(fp.currentItem()["path"])
+
+	if ctype, ok := FileExtContentTypes[ext]; ok {
+		return ctype
+	}
+
+	return "audio"
+}
+
+/*
+Artist returns the artist which is currently playing.
+*/
+func (fp *FilePlaylist) Artist() string {
+	return fp.currentItem()["artist"]
+}
+
+/*
+Title returns the title which is currently playing.
+*/
+func (fp *FilePlaylist) Title() string {
+	return fp.currentItem()["title"]
+}
+
+/*
+Frame returns the current audio frame which is playing.
+*/
+func (fp *FilePlaylist) Frame() ([]byte, error) {
+	var err error
+	var frame []byte
+
+	if fp.finished {
+		return nil, dudeldu.ErrPlaylistEnd
+	}
+
+	if fp.file == nil {
+
+		// Make sure first file is loaded
+
+		err = fp.nextFile()
+	}
+
+	if err == nil {
+
+		// Get new byte array from a pool
+
+		frame = fp.framePool.Get().([]byte)
+
+		n := 0
+		nn := 0
+
+		for n < len(frame) && err == nil {
+
+			nn, err = fp.file.Read(frame[n:])
+
+			n += nn
+
+			// Check if we need to read the next file
+
+			if n < len(frame) {
+				err = fp.nextFile()
+			}
+		}
+
+		// Make sure the frame has no old data if it was only partially filled
+
+		if n == 0 {
+
+			// Special case we reached the end of the playlist
+
+			frame = nil
+			if err != nil {
+				err = dudeldu.ErrPlaylistEnd
+			}
+
+		} else if n < len(frame) {
+
+			// Resize frame if we have less data
+
+			frame = frame[:n]
+		}
+	}
+
+	if err == dudeldu.ErrPlaylistEnd {
+		fp.finished = true
+	}
+
+	return frame, err
+}
+
+/*
+nextFile jumps to the next file for the playlist.
+*/
+func (fp *FilePlaylist) nextFile() error {
+
+	// Except for the first call advance the current pointer
+
+	if fp.file != nil {
+		fp.current++
+
+		fp.file.Close()
+		fp.file = nil
+
+		// Return special error if the end of the playlist has been reached
+
+		if fp.current >= len(fp.data) {
+			return dudeldu.ErrPlaylistEnd
+		}
+	}
+
+	// Check if a file is already open
+
+	if fp.file == nil {
+
+		// Open a new file
+
+		f, err := os.Open(fp.currentItem()["path"])
+		if err != nil {
+
+			// Jump to the next file if there is an error
+
+			fp.current++
+
+			return err
+		}
+
+		fp.file = f
+	}
+
+	return nil
+}
+
+/*
+ReleaseFrame releases a frame which has been written to the client.
+*/
+func (fp *FilePlaylist) ReleaseFrame(frame []byte) {
+	if len(frame) == FrameSize {
+		fp.framePool.Put(frame)
+	}
+}
+
+/*
+Finished returns if the playlist has finished playing.
+*/
+func (fp *FilePlaylist) Finished() bool {
+	return fp.finished
+}
+
+/*
+Close any open files by this playlist and reset the current pointer. After this
+call the playlist can be played again.
+*/
+func (fp *FilePlaylist) Close() error {
+	if fp.file != nil {
+		fp.file.Close()
+		fp.file = nil
+	}
+	fp.current = 0
+	fp.finished = false
+
+	return nil
+}

+ 505 - 0
playlist/fileplaylist_test.go

@@ -0,0 +1,505 @@
+/*
+ * DudelDu
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the MIT
+ * License, If a copy of the MIT License was not distributed with this
+ * file, You can obtain one at https://opensource.org/licenses/MIT.
+ */
+
+package playlist
+
+import (
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"sync"
+	"testing"
+
+	"devt.de/krotik/common/fileutil"
+	"devt.de/krotik/dudeldu"
+)
+
+const pdir = "playlisttest"
+
+const testPlaylist = `
+/*
+Test comment
+*/
+{
+	"/testpath" : [
+		{
+			"artist" : "artist1",  // 1234
+			"title"  : "test1",
+			"path"   : "playlisttest/test1.mp3"
+		},
+		{
+			"artist" : "artist2",
+			"title"  : "test2",
+			"path"   : "playlisttest/test2.nsv"
+		},
+		{
+			"artist" : "artist3",
+			"title"  : "test3",
+			"path"   : "playlisttest/test3.xyz"
+		}
+	]
+}`
+
+const testPlaylist2 = `{
+	"/testpath" : [
+		{
+			"artist" : "artist1",
+			"title"  : "test1",
+			"path"   : "playlisttest/test1.mp3"
+		},
+		{
+			"artist" : "artist2",
+			"title"  : "test2",
+			"path"   : "playlisttest/test2.nsv"
+		},
+		{
+			"artist" : "artist2",
+			"title"  : "test2",
+			"path"   : "playlisttest/nonexist"
+		},
+		{
+			"artist" : "artist3",
+			"title"  : "test3",
+			"path"   : "playlisttest/test3.xyz"
+		}
+	]
+}`
+
+const invalidFileName = "**" + string(0x0)
+
+func TestMain(m *testing.M) {
+	flag.Parse()
+
+	// Setup
+	if res, _ := fileutil.PathExists(pdir); res {
+		os.RemoveAll(pdir)
+	}
+
+	err := os.Mkdir(pdir, 0770)
+	if err != nil {
+		fmt.Print("Could not create test directory:", err.Error())
+		os.Exit(1)
+	}
+
+	// Run the tests
+	res := m.Run()
+
+	// Teardown
+	err = os.RemoveAll(pdir)
+	if err != nil {
+		fmt.Print("Could not remove test directory:", err.Error())
+	}
+
+	os.Exit(res)
+}
+
+func TestFilePlaylist(t *testing.T) {
+
+	// Set up
+
+	err := ioutil.WriteFile(pdir+"/test1.json", []byte(testPlaylist), 0644)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	err = ioutil.WriteFile(pdir+"/test2.json", []byte(testPlaylist2), 0644)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	err = ioutil.WriteFile(pdir+"/test1invalid.json", []byte(testPlaylist[2:]), 0644)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	err = ioutil.WriteFile(pdir+"/test1.mp3", []byte("123"), 0644)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	err = ioutil.WriteFile(pdir+"/test2.nsv", []byte("456789"), 0644)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	err = ioutil.WriteFile(pdir+"/test3.xyz", []byte("AB"), 0644)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	// Load invalid factory
+
+	_, err = NewFilePlaylistFactory(invalidFileName)
+	if err == nil {
+		t.Error(err)
+		return
+	}
+
+	_, err = NewFilePlaylistFactory(pdir + "/test1invalid.json")
+	if err.Error() != "invalid character '*' looking for beginning of value" {
+		t.Error(err)
+		return
+	}
+
+	// Create playlist factory
+
+	plf, err := NewFilePlaylistFactory(pdir + "/test1.json")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	// Request non-existing path
+
+	res := plf.Playlist("/nonexist", false)
+
+	if res != nil {
+		t.Error("Non existing path should return nil")
+		return
+	}
+
+	// Get existing playlist
+
+	pl := plf.Playlist("/testpath", false)
+	defer pl.Close()
+
+	if pl == nil {
+		t.Error("Playlist should exist")
+		return
+	}
+
+	if pl.Name() != "/testpath" {
+		t.Error("Unexpected playlist name:", pl.Name())
+		return
+	}
+
+	FrameSize = 2
+
+	if pl.ContentType() != "audio/mpeg" {
+		t.Error("Unexpected content type:", pl.ContentType())
+		return
+	}
+
+	if pl.Artist() != "artist1" {
+		t.Error("Unexpected artist:", pl.ContentType())
+		return
+	}
+
+	if pl.Title() != "test1" {
+		t.Error("Unexpected title:", pl.ContentType())
+		return
+	}
+
+	// Test close call
+
+	frame, err := pl.Frame()
+	if err != nil {
+		t.Error(err)
+		return
+	} else if string(frame) != "12" {
+		t.Error("Unexpected frame:", string(frame))
+		return
+	}
+
+	pl.Close()
+
+	// Make the frame pool run dry if more than one byte array is used
+
+	pl.(*FilePlaylist).framePool = &sync.Pool{}
+	pl.(*FilePlaylist).framePool.Put(make([]byte, 2, 2))
+
+	// Check that the right frames are returned
+
+	frame, err = pl.Frame()
+	if err != nil {
+		t.Error(err)
+		return
+	} else if string(frame) != "12" {
+		t.Error("Unexpected frame:", string(frame))
+		return
+	}
+	pl.ReleaseFrame(frame)
+
+	if pl.Title() != "test1" || pl.Artist() != "artist1" {
+		t.Error("Unexpected title/artist:", pl.Title(), pl.Artist())
+		return
+	}
+
+	frame, err = pl.Frame()
+	if err != nil {
+		t.Error(err)
+		return
+	} else if string(frame) != "34" {
+		t.Error("Unexpected frame:", string(frame))
+		return
+	}
+	pl.ReleaseFrame(frame)
+
+	if pl.Title() != "test2" || pl.Artist() != "artist2" {
+		t.Error("Unexpected title/artist:", pl.Title(), pl.Artist())
+		return
+	}
+
+	frame, err = pl.Frame()
+	if err != nil {
+		t.Error(err)
+		return
+	} else if string(frame) != "56" {
+		t.Error("Unexpected frame:", string(frame))
+		return
+	}
+	pl.ReleaseFrame(frame)
+
+	if pl.Title() != "test2" || pl.Artist() != "artist2" {
+		t.Error("Unexpected title/artist:", pl.Title(), pl.Artist())
+		return
+	}
+
+	frame, err = pl.Frame()
+	if err != nil {
+		t.Error(err)
+		return
+	} else if string(frame) != "78" {
+		t.Error("Unexpected frame:", string(frame))
+		return
+	}
+	pl.ReleaseFrame(frame)
+
+	frame, err = pl.Frame()
+	if err != nil {
+		t.Error(err)
+		return
+	} else if string(frame) != "9A" {
+		t.Error("Unexpected frame:", string(frame))
+		return
+	}
+	pl.ReleaseFrame(frame)
+
+	if pl.Title() != "test3" || pl.Artist() != "artist3" {
+		t.Error("Unexpected title/artist:", pl.Title(), pl.Artist())
+		return
+	}
+
+	// Check frame pool
+
+	if pl.(*FilePlaylist).framePool.Get() == nil {
+		t.Error("Frame pool should have an entry")
+		return
+	}
+	if pl.(*FilePlaylist).framePool.Get() != nil {
+		t.Error("Frame pool should have no entry")
+		return
+	}
+
+	// Put again one byte array back
+
+	pl.(*FilePlaylist).framePool.Put(make([]byte, 2, 2))
+
+	frame, err = pl.Frame()
+	if err != dudeldu.ErrPlaylistEnd {
+		t.Error(err)
+		return
+	} else if string(frame) != "B" {
+		t.Error("Unexpected frame:", string(frame), frame)
+		return
+	}
+	pl.ReleaseFrame(frame)
+
+	// Check that the byte array was NOT put back into the pool
+
+	if pl.(*FilePlaylist).framePool.Get() != nil {
+		t.Error("Frame pool should have no entry")
+		return
+	}
+
+	if !pl.Finished() {
+		t.Error("Playlist should be finished")
+		return
+	}
+
+	// Change the last file
+
+	err = ioutil.WriteFile(pdir+"/test3.xyz", []byte("A"), 0644)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	// Make the frame pool normal again
+
+	pl.(*FilePlaylist).framePool = &sync.Pool{New: func() interface{} { return make([]byte, FrameSize, FrameSize) }}
+
+	// Increase the framesize
+
+	FrameSize = 5
+	pl.Close()
+
+	frame, err = pl.Frame()
+	if err != nil {
+		t.Error(err)
+		return
+	} else if string(frame) != "12345" {
+		t.Error("Unexpected frame:", string(frame), frame)
+		return
+	}
+
+	// Check that the content type is unknown
+
+	if pl.ContentType() != "video/nsv" {
+		t.Error("Content type should be nsv not:", pl.ContentType())
+		return
+	}
+
+	frame, err = pl.Frame()
+	if err != nil {
+		t.Error(err)
+		return
+	} else if string(frame) != "6789A" {
+		t.Error("Unexpected frame:", string(frame), frame)
+		return
+	}
+
+	if pl.ContentType() != "audio" {
+		t.Error("Content type should be generic not:", pl.ContentType())
+		return
+	}
+
+	frame, err = pl.Frame()
+	if err != dudeldu.ErrPlaylistEnd {
+		t.Error(err)
+		return
+	} else if string(frame) != "" {
+		t.Error("Unexpected frame:", string(frame), frame)
+		return
+	}
+
+	if !pl.Finished() {
+		t.Error("Playlist should be finished")
+		return
+	}
+
+	// Increase the framesize
+
+	FrameSize = 10
+	pl.Close()
+
+	frame, err = pl.Frame()
+	if err != nil {
+		t.Error(err)
+		return
+	} else if string(frame) != "123456789A" {
+		t.Error("Unexpected frame:", string(frame), frame)
+		return
+	}
+
+	frame, err = pl.Frame()
+	if err != dudeldu.ErrPlaylistEnd {
+		t.Error(err)
+		return
+	} else if string(frame) != "" {
+		t.Error("Unexpected frame:", string(frame), frame)
+		return
+	}
+
+	if !pl.Finished() {
+		t.Error("Playlist should be finished")
+		return
+	}
+
+	// Increase the framesize
+
+	FrameSize = 11
+	pl.Close()
+
+	frame, err = pl.Frame()
+	if err != dudeldu.ErrPlaylistEnd {
+		t.Error(err)
+		return
+	} else if string(frame) != "123456789A" {
+		t.Error("Unexpected frame:", string(frame), frame)
+		return
+	}
+
+	if !pl.Finished() {
+		t.Error("Playlist should be finished")
+		return
+	}
+
+	// Check that the playlist has finished indeed
+
+	if _, err := pl.Frame(); err != dudeldu.ErrPlaylistEnd {
+		t.Error("Playlist end error expected")
+		return
+	}
+
+	// Create playlist factory
+
+	plf, err = NewFilePlaylistFactory(pdir + "/test2.json")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	// Test error
+
+	pl2 := plf.Playlist("/testpath", false)
+	defer pl2.Close()
+
+	FrameSize = 6
+
+	frame, err = pl2.Frame()
+	if err != nil {
+		t.Error(err)
+		return
+	} else if string(frame) != "123456" {
+		t.Error("Unexpected frame:", string(frame), frame)
+		return
+	}
+
+	frame, err = pl2.Frame()
+	if err.Error() != "open playlisttest/nonexist: The system cannot find the file specified." &&
+		err.Error() != "open playlisttest/nonexist: no such file or directory" {
+		t.Error(err)
+		return
+	} else if string(frame) != "789" {
+		t.Error("Unexpected frame:", string(frame), frame)
+		return
+	}
+
+	frame, err = pl2.Frame()
+	if err != dudeldu.ErrPlaylistEnd {
+		t.Error(err)
+		return
+	} else if string(frame) != "A" {
+		t.Error("Unexpected frame:", string(frame), frame)
+		return
+	}
+
+	// Make sure currentItem does not blow up
+
+	if pl2.Title() != "test3" {
+		t.Error("Unexpected result:", pl2.Title())
+		return
+	}
+
+	// Test shuffling
+
+	pl3 := plf.Playlist("/testpath", true)
+
+	if len(pl3.(*FilePlaylist).data) != len(pl2.(*FilePlaylist).data) {
+		t.Error("Length of playlists differ")
+		return
+	}
+}

+ 492 - 0
requesthandler.go

@@ -0,0 +1,492 @@
+/*
+ * DudelDu
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the MIT
+ * License, If a copy of the MIT License was not distributed with this
+ * file, You can obtain one at https://opensource.org/licenses/MIT.
+ */
+
+package dudeldu
+
+import (
+	"bytes"
+	"encoding/base64"
+	"fmt"
+	"io"
+	"log"
+	"math"
+	"net"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"devt.de/krotik/common/datautil"
+)
+
+/*
+MaxRequestSize is the maximum size for a request
+*/
+const MaxRequestSize = 1024
+
+/*
+MetaDataInterval is the data interval in which meta data is send
+*/
+var MetaDataInterval uint64 = 65536
+
+/*
+peerNoAuthTimeout is the time in seconds a peer can open new connections without
+sending new authentication information.
+*/
+const peerNoAuthTimeout = 10
+
+/*
+MaxMetaDataSize is the maximum size for meta data (everything over is truncated)
+
+Must be a multiple of 16 which fits into one byte. Maximum: 16 * 255 = 4080
+*/
+var MaxMetaDataSize = 4080
+
+/*
+requestPathPattern is the pattern which is used to extract the requested path
+(i case-insensitive / m multi-line mode: ^ and $ match begin/end line)
+*/
+var requestPathPattern = regexp.MustCompile("(?im)get\\s+([^\\s]+).*")
+
+/*
+requestOffsetPattern is the pattern which is used to extract the requested offset
+(i case-insensitive / m multi-line mode: ^ and $ match begin/end line)
+*/
+var requestOffsetPattern = regexp.MustCompile("(?im)^Range: bytes=([0-9]+)-.*$")
+
+/*
+requestAuthPattern is the pattern which is used to extract the request authentication
+(i case-insensitive / m multi-line mode: ^ and $ match begin/end line)
+*/
+var requestAuthPattern = regexp.MustCompile("(?im)^Authorization: Basic (\\S+).*$")
+
+/*
+Print logger method. Using a custom type so it can be customized.
+*/
+var Print = log.Print
+
+/*
+DebugOutput is a flag to enable additional debugging output
+*/
+var DebugOutput = false
+
+/*
+DefaultRequestHandler data structure
+*/
+type DefaultRequestHandler struct {
+	PlaylistFactory PlaylistFactory // Factory for playlists
+	ServeRequest    func(c net.Conn, path string,
+		metaDataSupport bool, offset int, auth string) // Function to serve requests
+	loop      bool               // Flag if the playlist should be looped
+	LoopTimes int                // Number of loops -1 loops forever
+	shuffle   bool               // Flag if the playlist should be shuffled
+	auth      string             // Required (basic) authentication string - may be empty
+	authPeers *datautil.MapCache // Peers which have been authenticated
+}
+
+/*
+NewDefaultRequestHandler creates a new default request handler object.
+*/
+func NewDefaultRequestHandler(pf PlaylistFactory, loop bool, shuffle bool, auth string) *DefaultRequestHandler {
+	drh := &DefaultRequestHandler{
+		PlaylistFactory: pf,
+		loop:            loop,
+		LoopTimes:       -1,
+		shuffle:         shuffle,
+		auth:            auth,
+		authPeers:       datautil.NewMapCache(0, peerNoAuthTimeout),
+	}
+	drh.ServeRequest = drh.defaultServeRequest
+	return drh
+}
+
+/*
+HandleRequest handles requests from streaming clients. It tries to extract
+the path and if meta data is supported. Once a request has been successfully
+decoded ServeRequest is called. The connection is closed once HandleRequest
+finishes.
+*/
+func (drh *DefaultRequestHandler) HandleRequest(c net.Conn, nerr net.Error) {
+
+	if DebugOutput {
+		Print("Handling request from: ", c.RemoteAddr())
+	}
+
+	defer func() {
+		c.Close()
+	}()
+
+	// Check if there was an error
+
+	if nerr != nil {
+		Print(nerr)
+		return
+	}
+
+	rbuf := make([]byte, 512, 512)
+	var buf bytes.Buffer
+
+	// Decode request
+
+	n, err := c.Read(rbuf)
+
+	for n > 0 || (err != nil && err != io.EOF) {
+
+		// Do some error checking
+
+		if err != nil {
+			Print(err)
+			return
+		} else if buf.Len() > MaxRequestSize {
+			Print("Illegal request: Request is too long")
+			return
+		}
+
+		buf.Write(rbuf[:n])
+
+		if strings.Contains(string(rbuf), "\r\n\r\n") {
+			break
+		}
+
+		n, err = c.Read(rbuf)
+	}
+
+	// Add ending sequence in case the client "forgets"
+
+	bufStr := buf.String() + "\r\n\r\n"
+
+	// Determine the remote string
+
+	clientString := "-"
+	if c.RemoteAddr() != nil {
+		clientString, _, _ = net.SplitHostPort(c.RemoteAddr().String())
+	}
+
+	if DebugOutput {
+		Print("Client:", c.RemoteAddr(), " Request:", bufStr)
+	}
+
+	if i := strings.Index(bufStr, "\r\n\r\n"); i >= 0 {
+		bufStr = strings.TrimSpace(bufStr[:i])
+
+		// Check authentication
+
+		auth := ""
+		res := requestAuthPattern.FindStringSubmatch(bufStr)
+		origBufStr, hasAuth := drh.authPeers.Get(clientString)
+
+		if len(res) > 1 {
+
+			// Decode authentication
+
+			b, err := base64.StdEncoding.DecodeString(res[1])
+			if err != nil {
+				drh.writeUnauthorized(c)
+				Print("Invalid request (cannot decode authentication): ", bufStr)
+				return
+			}
+
+			auth = string(b)
+
+			// Authorize request
+
+			if auth != drh.auth && drh.auth != "" {
+
+				if DebugOutput {
+					Print("Wrong authentication:", string(b))
+				}
+
+				drh.writeUnauthorized(c)
+				return
+			}
+
+			// Peer is now authorized store this so it can connect again
+
+			drh.authPeers.Put(clientString, bufStr)
+
+		} else if drh.auth != "" && !hasAuth {
+
+			// No authorization
+
+			if DebugOutput {
+				Print("No authentication found")
+			}
+
+			drh.writeUnauthorized(c)
+			return
+
+		} else if bufStr == "" && hasAuth {
+
+			// Workaround for strange clients like VLC which send first the
+			// authentication then connect again on a different port and just
+			// expect the stream
+
+			bufStr = origBufStr.(string)
+
+			// Get again the authentication
+
+			res = requestAuthPattern.FindStringSubmatch(bufStr)
+
+			if len(res) > 1 {
+				if b, err := base64.StdEncoding.DecodeString(res[1]); err == nil {
+					auth = string(b)
+				}
+			}
+		}
+
+		// Check if the client supports meta data
+
+		metaDataSupport := false
+
+		if strings.Contains(strings.ToLower(bufStr), "icy-metadata: 1") {
+			metaDataSupport = true
+		}
+
+		// Extract offset
+
+		offset := 0
+		res = requestOffsetPattern.FindStringSubmatch(bufStr)
+
+		if len(res) > 1 {
+
+			if o, err := strconv.Atoi(res[1]); err == nil {
+				offset = o
+			}
+		}
+
+		// Extract the path
+
+		res = requestPathPattern.FindStringSubmatch(bufStr)
+
+		if len(res) > 1 {
+
+			// Now serve the request
+
+			drh.ServeRequest(c, res[1], metaDataSupport, offset, auth)
+
+			return
+		}
+	}
+
+	Print("Invalid request: ", bufStr)
+}
+
+/*
+defaultServeRequest is called once a request was successfully decoded.
+*/
+func (drh *DefaultRequestHandler) defaultServeRequest(c net.Conn, path string, metaDataSupport bool, offset int, auth string) {
+	var err error
+
+	if DebugOutput {
+		Print("Serve request path:", path, " Metadata support:", metaDataSupport, " Offset:", offset)
+	}
+
+	pl := drh.PlaylistFactory.Playlist(path, drh.shuffle)
+	if pl == nil {
+
+		// Stream was not found - no error checking here (don't care)
+
+		drh.writeStreamNotFoundResponse(c)
+		return
+	}
+
+	err = drh.writeStreamStartResponse(c, pl.Name(), pl.ContentType(), metaDataSupport)
+
+	clientWritten := 0
+	var writtenBytes uint64
+	currentPlaying := ""
+	frameOffset := offset
+
+	for {
+
+		for !pl.Finished() {
+
+			if DebugOutput {
+				playingString := fmt.Sprintf("%v - %v", pl.Title(), pl.Artist())
+
+				if playingString != currentPlaying {
+					currentPlaying = playingString
+					Print("Written bytes: ", writtenBytes)
+					Print("Sending: ", currentPlaying)
+				}
+			}
+
+			// Check if there were any errors
+
+			if err != nil {
+				Print(err)
+				return
+			}
+
+			frame, err := pl.Frame()
+
+			// Handle offsets
+
+			if frameOffset > 0 && err == nil {
+
+				for frameOffset > len(frame) && err == nil {
+					frameOffset -= len(frame)
+					frame, err = pl.Frame()
+				}
+
+				if err == nil {
+					frame = frame[frameOffset:]
+					frameOffset = 0
+
+					if len(frame) == 0 {
+						frame, err = pl.Frame()
+					}
+				}
+			}
+
+			if frame == nil {
+				if !pl.Finished() {
+					Print(fmt.Sprintf("Empty frame for: %v - %v (Error: %v)", pl.Title(), pl.Artist(), err))
+				}
+				continue
+			} else if err != nil {
+				if err != ErrPlaylistEnd {
+					Print(fmt.Sprintf("Error while retrieving playlist data: %v", err))
+				}
+				err = nil
+			}
+
+			// Check if meta data should be send
+
+			if metaDataSupport && writtenBytes+uint64(len(frame)) >= MetaDataInterval {
+
+				// Write rest data before sending meta data
+
+				preMetaDataLength := MetaDataInterval - writtenBytes
+				if preMetaDataLength > 0 {
+					if err == nil {
+
+						_, err = c.Write(frame[:preMetaDataLength])
+
+						frame = frame[preMetaDataLength:]
+						writtenBytes += preMetaDataLength
+					}
+				}
+
+				if err == nil {
+
+					// Write meta data - no error checking (next write should fail)
+
+					drh.writeStreamMetaData(c, pl)
+
+					// Write rest of the frame
+
+					c.Write(frame)
+					writtenBytes += uint64(len(frame))
+				}
+
+				writtenBytes -= MetaDataInterval
+
+			} else {
+
+				// Just write the frame to the client
+
+				if err == nil {
+
+					clientWritten, _ = c.Write(frame)
+
+					// Abort if the client does not accept more data
+
+					if clientWritten == 0 && len(frame) > 0 {
+						Print(fmt.Sprintf("Could not write to client - closing connection"))
+						return
+					}
+				}
+
+				pl.ReleaseFrame(frame)
+
+				writtenBytes += uint64(len(frame))
+			}
+		}
+
+		// Handle looping - do not loop if close returns an error
+
+		if pl.Close() != nil || !drh.loop {
+			break
+		} else if drh.LoopTimes != -1 {
+			drh.LoopTimes--
+			if drh.LoopTimes == 0 {
+				break
+			}
+		}
+	}
+
+	if DebugOutput {
+		Print("Serve request path:", path, " complete")
+	}
+}
+
+/*
+writeStreamMetaData writes meta data information into the stream.
+*/
+func (drh *DefaultRequestHandler) writeStreamMetaData(c net.Conn, playlist Playlist) {
+	streamTitle := fmt.Sprintf("StreamTitle='%v - %v';", playlist.Title(), playlist.Artist())
+
+	// Truncate stream title if necessary
+
+	if len(streamTitle) > MaxMetaDataSize {
+		streamTitle = streamTitle[:MaxMetaDataSize-2] + "';"
+	}
+
+	// Calculate the meta data frame size as a multiple of 16
+
+	metaDataFrameSize := byte(math.Ceil(float64(len(streamTitle)) / 16.0))
+
+	// Write meta data to the client
+
+	metaData := make([]byte, 16.0*metaDataFrameSize+1, 16.0*metaDataFrameSize+1)
+	metaData[0] = metaDataFrameSize
+
+	copy(metaData[1:], streamTitle)
+
+	c.Write(metaData)
+}
+
+/*
+writeStreamStartResponse writes the start response to the client.
+*/
+func (drh *DefaultRequestHandler) writeStreamStartResponse(c net.Conn,
+	name, contentType string, metaDataSupport bool) error {
+
+	c.Write([]byte("ICY 200 OK\r\n"))
+	c.Write([]byte(fmt.Sprintf("Content-Type: %v\r\n", contentType)))
+	c.Write([]byte(fmt.Sprintf("icy-name: %v\r\n", name)))
+
+	if metaDataSupport {
+		c.Write([]byte("icy-metadata: 1\r\n"))
+		c.Write([]byte(fmt.Sprintf("icy-metaint: %v\r\n", MetaDataInterval)))
+	}
+
+	_, err := c.Write([]byte("\r\n"))
+
+	return err
+}
+
+/*
+writeStreamNotFoundResponse writes the not found response to the client.
+*/
+func (drh *DefaultRequestHandler) writeStreamNotFoundResponse(c net.Conn) error {
+	_, err := c.Write([]byte("HTTP/1.1 404 Not found\r\n\r\n"))
+
+	return err
+}
+
+/*
+writeUnauthorized writes the Unauthorized response to the client.
+*/
+func (drh *DefaultRequestHandler) writeUnauthorized(c net.Conn) error {
+	_, err := c.Write([]byte("HTTP/1.1 401 Authorization Required\r\nWWW-Authenticate: Basic realm=\"DudelDu Streaming Server\"\r\n\r\n"))
+
+	return err
+}

+ 647 - 0
requesthandler_test.go

@@ -0,0 +1,647 @@
+/*
+ * DudelDu
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the MIT
+ * License, If a copy of the MIT License was not distributed with this
+ * file, You can obtain one at https://opensource.org/licenses/MIT.
+ */
+
+package dudeldu
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"log"
+	"net"
+	"strings"
+	"sync"
+	"testing"
+
+	"devt.de/krotik/common/testutil"
+)
+
+const testRequest = `
+GET /mylist HTTP/1.1
+Host: localhost:9091
+User-Agent: VLC/2.2.1 LibVLC/2.2.1
+Range: bytes=0-
+Connection: close
+Icy-MetaData: 1` +
+	"\r\n\r\n"
+
+const testRequest2 = `
+GET /mylist2 HTTP/1.1
+Host: localhost:9091
+User-Agent: VLC/2.2.1 LibVLC/2.2.1
+Range: bytes=656-
+Connection: close
+Icy-MetaData: 1` +
+	"\r\n\r\n"
+
+const testRequest3 = `
+GET /bach/cello_suite1 HTTP/1.1
+Host: localhost:9091
+User-Agent: Mozilla/5.0 (Windows NT 6.3; WOW64; rv:48.0) Gecko/20100101 Firefox/99.0
+Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
+Accept-Language: en-US,en;q=0.5
+Accept-Encoding: gzip, deflate
+Authorization: Basic d2ViOndlYg==
+Connection: keep-alive
+Upgrade-Insecure-Requests: 1
+Cache-Control: max-age=0
+`
+
+const testRequest4 = "GET /bach/cello_suite1 HTTP/1.1\r\nHost: localhost:9091\r\n" +
+	"User-Agent: Mozilla/5.0 (Windows NT 6.3; WOW64; rv:48.0) Gecko/20100101 Firefox/48.0\r\n" +
+	"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.5\r\n" +
+	"Accept-Encoding: gzip, deflate\r\n" +
+	"Authorization: Basic d2ViOndlYg==\r\n" +
+	"Connection: keep-alive\r\nUpgrade-Insecure-Requests: 1\r\nCache-Control: max-age=0"
+
+const testRequest5 = `
+GET /mylist2 HTTP/1.1
+Host: localhost:9091
+User-Agent: VLC/2.2.1 LibVLC/2.2.1
+Range: bytes=656-
+Authorization: Basic erghb4
+Connection: close
+Icy-MetaData: 1` +
+	"\r\n\r\n"
+
+/*
+testNetError is am error for testing
+*/
+type testNetError struct {
+}
+
+func (t *testNetError) Error() string {
+	return "TestNetError"
+}
+
+func (t *testNetError) Timeout() bool {
+	return false
+}
+
+func (t *testNetError) Temporary() bool {
+	return false
+}
+
+type testPlaylistFactory struct {
+	RetPlaylist Playlist
+}
+
+func (tp *testPlaylistFactory) Playlist(path string, shuffle bool) Playlist {
+	if path == "/testpath" {
+		return tp.RetPlaylist
+	}
+	return nil
+}
+
+var testTitle = "Test Title"
+
+/*
+testPlaylist is a playlist for testing
+*/
+type testPlaylist struct {
+	Frames [][]byte
+	Errors []error
+	fp     int
+}
+
+func (tp *testPlaylist) Name() string {
+	return "TestPlaylist"
+}
+
+func (tp *testPlaylist) ContentType() string {
+	return "Test/Content"
+}
+
+func (tp *testPlaylist) Artist() string {
+	return "Test Artist"
+}
+
+func (tp *testPlaylist) Title() string {
+	return testTitle
+}
+
+func (tp *testPlaylist) Frame() ([]byte, error) {
+	var err error
+	f := tp.Frames[tp.fp]
+	if tp.Errors != nil {
+		err = tp.Errors[tp.fp]
+	}
+	tp.fp++
+	return f, err
+}
+
+func (tp *testPlaylist) ReleaseFrame([]byte) {
+}
+
+func (tp *testPlaylist) Finished() bool {
+	return tp.fp == len(tp.Frames)
+}
+
+func (tp *testPlaylist) Close() error {
+	tp.fp = 0
+	return nil
+}
+
+func TestRequestServing(t *testing.T) {
+
+	DebugOutput = true
+
+	var out bytes.Buffer
+
+	// Collect the print output
+	Print = func(v ...interface{}) {
+		out.WriteString(fmt.Sprint(v...))
+		out.WriteString("\n")
+	}
+	defer func() {
+		Print = log.Print
+	}()
+
+	drh := NewDefaultRequestHandler(&testPlaylistFactory{}, false, false, "")
+	testConn := &testutil.ErrorTestingConnection{}
+
+	// Test a path not found
+
+	drh.defaultServeRequest(testConn, "tester", false, 0, "")
+
+	if testConn.Out.String() != "HTTP/1.1 404 Not found\r\n\r\n" {
+		t.Error("Unexpected response:", testConn.Out.String())
+		return
+	}
+
+	// Test straight forward case - serving a stream without meta data
+
+	drh = NewDefaultRequestHandler(&testPlaylistFactory{&testPlaylist{
+		[][]byte{[]byte("12"), nil, []byte("3")},
+		[]error{nil, nil, errors.New("TestError")},
+		0}}, false, false, "")
+	testConn = &testutil.ErrorTestingConnection{}
+
+	out.Reset()
+
+	drh.defaultServeRequest(testConn, "/testpath", false, 0, "")
+
+	if testConn.Out.String() != "ICY 200 OK\r\n"+
+		"Content-Type: Test/Content\r\n"+
+		"icy-name: TestPlaylist\r\n"+
+		"\r\n"+
+		"123" {
+
+		t.Error("Unexpected response:", testConn.Out.String())
+		return
+	}
+
+	if out.String() != "Serve request path:/testpath Metadata support:false Offset:0\n"+
+		"Written bytes: 0\n"+
+		"Sending: Test Title - Test Artist\n"+
+		"Empty frame for: Test Title - Test Artist (Error: <nil>)\n"+
+		"Error while retrieving playlist data: TestError\n"+
+		"Serve request path:/testpath complete\n" {
+		t.Error("Unexpected out string:", out.String())
+		return
+	}
+
+	// Test case when sending meta data
+
+	oldMetaDataInterval := MetaDataInterval
+	MetaDataInterval = 5
+	defer func() {
+		MetaDataInterval = oldMetaDataInterval
+	}()
+
+	tpl := &testPlaylist{[][]byte{[]byte("123"), []byte("4567"), []byte("0123"), []byte("456789")}, nil, 0}
+	drh = NewDefaultRequestHandler(&testPlaylistFactory{tpl}, false, false, "")
+	testConn = &testutil.ErrorTestingConnection{}
+
+	drh.defaultServeRequest(testConn, "/testpath", true, 0, "")
+
+	// Meta data is 3*16=48 bytes - text is 39 bytes, padding is 9 bytes
+
+	if testConn.Out.String() != ("ICY 200 OK\r\n" +
+		"Content-Type: Test/Content\r\n" +
+		"icy-name: TestPlaylist\r\n" +
+		"icy-metadata: 1\r\n" +
+		"icy-metaint: 5\r\n" +
+		"\r\n" +
+		`12345` + string(0x03) + `StreamTitle='Test Title - Test Artist';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`67012` + string(0x03) + `StreamTitle='Test Title - Test Artist';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`34567` + string(0x03) + `StreamTitle='Test Title - Test Artist';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`89`) {
+
+		t.Error("Unexpected response:", testConn.Out.String())
+		return
+	}
+
+	tpl.fp = 0
+	drh = NewDefaultRequestHandler(&testPlaylistFactory{tpl}, false, false, "")
+	testConn = &testutil.ErrorTestingConnection{}
+	testConn.OutErr = 5
+	out.Reset()
+
+	drh.defaultServeRequest(testConn, "/testpath", true, 0, "")
+
+	if out.String() != "Serve request path:/testpath Metadata support:true Offset:0\n"+
+		"Written bytes: 0\n"+
+		"Sending: Test Title - Test Artist\n"+
+		"Test writing error\n" {
+		t.Error("Unexpected output:", out.String())
+		return
+	}
+
+	oldTestTitle := testTitle
+	testTitle = "A very long title name which should be truncated"
+	defer func() {
+		testTitle = oldTestTitle
+	}()
+
+	oldMaxMetaDataSize := MaxMetaDataSize
+	MaxMetaDataSize = 40
+	defer func() {
+		MaxMetaDataSize = oldMaxMetaDataSize
+	}()
+
+	tpl.fp = 0
+	drh = NewDefaultRequestHandler(&testPlaylistFactory{tpl}, false, false, "")
+	testConn = &testutil.ErrorTestingConnection{}
+
+	drh.defaultServeRequest(testConn, "/testpath", true, 0, "")
+
+	// Meta data is 3*16=48 bytes - text is 40 bytes, padding is 8 bytes
+
+	if testConn.Out.String() != ("ICY 200 OK\r\n" +
+		"Content-Type: Test/Content\r\n" +
+		"icy-name: TestPlaylist\r\n" +
+		"icy-metadata: 1\r\n" +
+		"icy-metaint: 5\r\n" +
+		"\r\n" +
+		`12345` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`67012` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`34567` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`89`) {
+
+		t.Error("Unexpected response:", testConn.Out.String())
+		return
+	}
+
+	// Test offsets
+
+	tpl.fp = 0
+	drh = NewDefaultRequestHandler(&testPlaylistFactory{tpl}, false, false, "")
+	testConn = &testutil.ErrorTestingConnection{}
+
+	drh.defaultServeRequest(testConn, "/testpath", true, 7, "")
+
+	// Meta data is 3*16=48 bytes - text is 40 bytes, padding is 8 bytes
+
+	if testConn.Out.String() != ("ICY 200 OK\r\n" +
+		"Content-Type: Test/Content\r\n" +
+		"icy-name: TestPlaylist\r\n" +
+		"icy-metadata: 1\r\n" +
+		"icy-metaint: 5\r\n" +
+		"\r\n" +
+		`01234` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`56789`) {
+
+		t.Error("Unexpected response:", testConn.Out.String())
+		return
+	}
+
+	tpl.fp = 0
+	drh = NewDefaultRequestHandler(&testPlaylistFactory{tpl}, false, false, "")
+	testConn = &testutil.ErrorTestingConnection{}
+
+	drh.defaultServeRequest(testConn, "/testpath", true, 2, "")
+
+	// Meta data is 3*16=48 bytes - text is 40 bytes, padding is 8 bytes
+
+	if testConn.Out.String() != ("ICY 200 OK\r\n" +
+		"Content-Type: Test/Content\r\n" +
+		"icy-name: TestPlaylist\r\n" +
+		"icy-metadata: 1\r\n" +
+		"icy-metaint: 5\r\n" +
+		"\r\n" +
+		`34567` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`01234` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`56789`) {
+
+		t.Error("Unexpected response:", testConn.Out.String())
+		return
+	}
+
+	// Test offset and loops
+
+	tpl.fp = 0
+	drh = NewDefaultRequestHandler(&testPlaylistFactory{tpl}, true, false, "")
+	testConn = &testutil.ErrorTestingConnection{}
+	drh.LoopTimes = 3
+
+	drh.defaultServeRequest(testConn, "/testpath", true, 4, "")
+
+	// Meta data is 3*16=48 bytes - text is 40 bytes, padding is 8 bytes
+
+	if testConn.Out.String() != ("ICY 200 OK\r\n" +
+		"Content-Type: Test/Content\r\n" +
+		"icy-name: TestPlaylist\r\n" +
+		"icy-metadata: 1\r\n" +
+		"icy-metaint: 5\r\n" +
+		"\r\n" +
+		`56701` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`23456` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`78912` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`34567` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`01234` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`56789` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`12345` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`67012` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`34567` + string(0x03) + `StreamTitle='A very long title name wh';` + string([]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}) +
+		`89`) {
+
+		t.Error("Unexpected response:", testConn.Out.String())
+		return
+	}
+
+	// Test client close connection
+
+	tpl.fp = 0
+	drh = NewDefaultRequestHandler(&testPlaylistFactory{tpl}, false, false, "")
+	testConn = &testutil.ErrorTestingConnection{}
+	testConn.OutClose = true
+	out.Reset()
+
+	drh.defaultServeRequest(testConn, "/testpath", true, 0, "")
+
+	if out.String() != "Serve request path:/testpath Metadata support:true Offset:0\n"+
+		"Written bytes: 0\n"+
+		"Sending: A very long title name which should be truncated - Test Artist\n"+
+		"Could not write to client - closing connection\n" {
+		t.Error("Unexpected output:", out.String())
+		return
+	}
+
+}
+
+func TestRequestHandling(t *testing.T) {
+
+	DebugOutput = true
+
+	var out bytes.Buffer
+
+	// Collect the print output
+	Print = func(v ...interface{}) {
+		out.WriteString(fmt.Sprint(v...))
+		out.WriteString("\n")
+	}
+	defer func() {
+		Print = log.Print
+	}()
+
+	drh := NewDefaultRequestHandler(nil, false, false, "")
+	testConn := &testutil.ErrorTestingConnection{}
+
+	// Check normal error return
+
+	drh.HandleRequest(testConn, &testNetError{})
+
+	if out.String() != "Handling request from: <nil>\n"+
+		"TestNetError\n" {
+		t.Error("Unexpected output:", out.String())
+		return
+	}
+
+	out.Reset()
+
+	// Test connection writing errors
+
+	testConn = &testutil.ErrorTestingConnection{}
+	for i := 0; i < 1600; i++ {
+		testConn.In.WriteString("0123456789")
+	}
+	testConn.InErr = 530
+
+	drh.HandleRequest(testConn, nil)
+
+	if out.String() != "Handling request from: <nil>\n"+
+		"Test reading error\n" {
+		t.Error("Unexpected output:", out.String())
+		return
+	}
+
+	out.Reset()
+	testConn.In.Reset()
+	for i := 0; i < 1600; i++ {
+		testConn.In.WriteString("0123456789")
+	}
+	testConn.InErr = 0
+
+	drh.HandleRequest(testConn, nil)
+
+	if out.String() != "Handling request from: <nil>\n"+
+		"Illegal request: Request is too long\n" {
+		t.Error("Unexpected output:", out.String())
+		return
+	}
+
+	out.Reset()
+	testConn.In.Reset()
+	testConn.In.WriteString("123")
+	testConn.InErr = 0
+
+	drh.HandleRequest(testConn, nil)
+
+	if out.String() != "Handling request from: <nil>\n"+
+		"Client:<nil> Request:123\r\n\r\n\n"+
+		"Invalid request: 123\n" {
+		t.Error("Unexpected output:", out.String())
+		return
+	}
+
+	// Test auth
+
+	drh = NewDefaultRequestHandler(nil, false, false, "web:web")
+	testConn = &testutil.ErrorTestingConnection{}
+
+	testConn.In.Reset()
+	testConn.In.WriteString(testRequest5)
+
+	// Check normal error return
+
+	drh.HandleRequest(testConn, nil)
+
+	if !strings.Contains(out.String(), "Invalid request (cannot decode authentication)") {
+		t.Error("Unexpected output:", out.String())
+		return
+	}
+
+	out.Reset()
+
+	testConn.In.Reset()
+	testConn.In.WriteString(testRequest2)
+
+	// Check normal error return
+
+	drh.HandleRequest(testConn, nil)
+
+	if !strings.Contains(out.String(), "No authentication found") {
+		t.Error("Unexpected output:", out.String())
+		return
+	}
+
+	out.Reset()
+
+	drh = NewDefaultRequestHandler(nil, false, false, "web:web2")
+	testConn = &testutil.ErrorTestingConnection{}
+
+	testConn.In.Reset()
+	testConn.In.WriteString(testRequest3)
+
+	// Check normal error return
+
+	drh.HandleRequest(testConn, nil)
+
+	if !strings.Contains(out.String(), "Wrong authentication:web:web") {
+		t.Error("Unexpected output:", out.String())
+		return
+	}
+
+	out.Reset()
+}
+
+func TestRequestHandler(t *testing.T) {
+
+	DebugOutput = true
+
+	var out bytes.Buffer
+
+	// Collect the print output
+	Print = func(v ...interface{}) {
+		out.WriteString(fmt.Sprint(v...))
+		out.WriteString("\n")
+	}
+	defer func() {
+		Print = log.Print
+	}()
+
+	drh := NewDefaultRequestHandler(nil, false, false, "")
+	dds := NewServer(drh.HandleRequest)
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+
+	go func() {
+		err := dds.Run(testport, &wg)
+		if err != nil {
+			t.Error(err)
+			return
+		}
+	}()
+
+	wg.Wait()
+
+	rpath := ""
+	rmetaDataSupport := false
+	roffset := -1
+	rauth := ""
+	errorChan := make(chan error)
+
+	drh.ServeRequest = func(c net.Conn, path string, metaDataSupport bool, offset int, auth string) {
+		rpath = path
+		rmetaDataSupport = metaDataSupport
+		roffset = offset
+		rauth = auth
+		errorChan <- nil
+	}
+	defer func() {
+		drh.ServeRequest = drh.defaultServeRequest
+	}()
+
+	// Server is now running
+
+	if err := writeSocket([]byte(testRequest)); err != nil {
+		t.Error(err)
+		return
+	}
+
+	<-errorChan
+
+	if rpath != "/mylist" || rmetaDataSupport != true || roffset != 0 || rauth != "" {
+		t.Error("Unexpected request decoding result:", rpath, rmetaDataSupport, roffset)
+		return
+	}
+
+	if err := writeSocket([]byte(testRequest2)); err != nil {
+		t.Error(err)
+		return
+	}
+
+	<-errorChan
+
+	if rpath != "/mylist2" || rmetaDataSupport != true || roffset != 656 || rauth != "" {
+		t.Error("Unexpected request decoding result:", rpath, rmetaDataSupport, roffset)
+		return
+	}
+
+	if err := writeSocket([]byte(testRequest3)); err != nil {
+		t.Error(err)
+		return
+	}
+
+	<-errorChan
+
+	if rpath != "/bach/cello_suite1" || rmetaDataSupport != false || roffset != 0 || rauth != "web:web" {
+		t.Error("Unexpected request decoding result:", rpath, rmetaDataSupport, roffset, rauth)
+		return
+	}
+
+	if err := writeSocket([]byte(testRequest4)); err != nil {
+		t.Error(err)
+		return
+	}
+
+	<-errorChan
+
+	if rpath != "/bach/cello_suite1" || rmetaDataSupport != false || roffset != 0 || rauth != "web:web" {
+		t.Error("Unexpected request decoding result:", rpath, rmetaDataSupport, roffset, rauth)
+		fmt.Println(testRequest4)
+		return
+	}
+
+	if err := writeSocket([]byte("\r\n")); err != nil {
+		t.Error(err)
+		return
+	}
+
+	<-errorChan
+
+	if rpath != "/bach/cello_suite1" || rmetaDataSupport != false || roffset != 0 || rauth != "web:web" {
+		t.Error("Unexpected request decoding result:", rpath, rmetaDataSupport, roffset, rauth)
+		fmt.Println(testRequest4)
+		return
+	}
+
+	// Shutdown server
+
+	wg.Add(1)
+
+	dds.Shutdown()
+
+	wg.Wait()
+}
+
+func writeSocket(req []byte) error {
+	conn, err := net.Dial("tcp", testport)
+	if err != nil {
+		return err
+	}
+	defer conn.Close()
+
+	conn.Write(req)
+
+	return nil
+}

+ 1 - 0
rufs.secret

@@ -0,0 +1 @@
+ÈNw^A:›AI1n¹

+ 7 - 0
rufs.server.json

@@ -0,0 +1,7 @@
+{
+    "BranchName": "172.17.0.2",
+    "EnableReadOnly": false,
+    "LocalFolder": "share",
+    "RPCHost": "172.17.0.2",
+    "RPCPort": "9020"
+}

+ 175 - 0
server.go

@@ -0,0 +1,175 @@
+/*
+ * DudelDu
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the MIT
+ * License, If a copy of the MIT License was not distributed with this
+ * file, You can obtain one at https://opensource.org/licenses/MIT.
+ */
+
+package dudeldu
+
+import (
+	"net"
+	"os"
+	"os/signal"
+	"sync"
+	"syscall"
+	"time"
+)
+
+/*
+ProductVersion is the current version of DudelDu
+*/
+const ProductVersion = "0.0.0"
+
+/*
+ConnectionHandler is a function to handle new connections
+*/
+type ConnectionHandler func(net.Conn, net.Error)
+
+/*
+Server data structure
+*/
+type Server struct {
+	Running     bool              // Flag indicating if the server is running
+	Handler     ConnectionHandler // Handler function for new  connections
+	signalling  chan os.Signal    // Channel for receiving signals
+	tcpListener *net.TCPListener  // TCP listener which accepts connections
+	serving     bool              // Internal flag indicating if the socket should be served
+	wgStatus    *sync.WaitGroup   // Optional wait group which should be notified once the server has started
+}
+
+/*
+NewServer creates a new DudelDu server.
+*/
+func NewServer(handler ConnectionHandler) *Server {
+	return &Server{
+		Running: false,
+		Handler: handler,
+	}
+}
+
+/*
+Run starts the DudelDu Server which can be stopped via ^C (Control-C).
+
+laddr should be the local address which should be given to net.Listen.
+wgStatus is an optional wait group which will be notified once the server is listening
+and once the server has shutdown.
+
+This function will not return unless the server is shutdown.
+*/
+func (ds *Server) Run(laddr string, wgStatus *sync.WaitGroup) error {
+
+	// Create listener
+
+	listener, err := net.Listen("tcp", laddr)
+
+	if err != nil {
+		if wgStatus != nil {
+			wgStatus.Done()
+		}
+
+		return err
+	}
+
+	ds.tcpListener = listener.(*net.TCPListener)
+	ds.wgStatus = wgStatus
+
+	// Attach SIGINT handler - on unix and windows this is send
+	// when the user presses ^C (Control-C).
+
+	ds.signalling = make(chan os.Signal)
+	signal.Notify(ds.signalling, syscall.SIGINT)
+
+	// Put the serve call into a wait group so we can wait until shutdown
+	// completed
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+
+	// Kick off the serve thread
+
+	go func() {
+		defer wg.Done()
+
+		ds.Running = true
+		ds.serv()
+	}()
+
+	for {
+
+		// Listen for shutdown signal
+
+		if DebugOutput {
+			Print("Listen for shutdown signal")
+		}
+
+		signal := <-ds.signalling
+
+		if signal == syscall.SIGINT {
+
+			// Shutdown the server
+
+			ds.serving = false
+
+			// Wait until the server has shut down
+
+			wg.Wait()
+
+			ds.Running = false
+
+			break
+		}
+	}
+
+	if wgStatus != nil {
+		wgStatus.Done()
+	}
+
+	return nil
+}
+
+/*
+Shutdown sends a shutdown signal.
+*/
+func (ds *Server) Shutdown() {
+	if ds.serving {
+		ds.signalling <- syscall.SIGINT
+	}
+}
+
+/*
+serv waits for new connections and assigns a handler to them.
+*/
+func (ds *Server) serv() {
+
+	ds.serving = true
+
+	for ds.serving {
+
+		// Wait up to a second for a new connection
+
+		ds.tcpListener.SetDeadline(time.Now().Add(time.Second))
+		newConn, err := ds.tcpListener.Accept()
+
+		// Notify wgStatus if it was specified
+
+		if ds.wgStatus != nil {
+			ds.wgStatus.Done()
+			ds.wgStatus = nil
+		}
+
+		netErr, ok := err.(net.Error)
+
+		// Check if got an error and notify an error handler
+
+		if newConn != nil || (ok && !(netErr.Timeout() || netErr.Temporary())) {
+
+			go ds.Handler(newConn, netErr)
+		}
+	}
+
+	ds.tcpListener.Close()
+}

+ 142 - 0
server/dudeldu.go

@@ -0,0 +1,142 @@
+/*
+ * DudelDu
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the MIT
+ * License, If a copy of the MIT License was not distributed with this
+ * file, You can obtain one at https://opensource.org/licenses/MIT.
+ */
+
+/*
+DudelDu main entry point for the standalone server.
+
+Features:
+
+- Supports various streaming clients: VLC, ServeStream, ... and most Icecast clients.
+
+- Supports sending of meta data (sending artist and title to the streaming client).
+
+- Playlists are simple json files and data files are normal media (e.g. .mp3) files on disk.
+
+- Supports basic authentication.
+*/
+package main
+
+import (
+	"flag"
+	"fmt"
+	"log"
+	"os"
+
+	"devt.de/krotik/dudeldu"
+	"devt.de/krotik/dudeldu/playlist"
+)
+
+// Global variables
+// ================
+
+/*
+ConfigFile is the config file which will be used to configure DudelDu
+*/
+var ConfigFile = "dudeldu.config.json"
+
+/*
+Known configuration options for DudelDu
+*/
+const (
+	ThreadPoolSize = "ThreadPoolSize"
+	FrameQueueSize = "FrameQueueSize"
+	ServerPort     = "ServerPort"
+	ServerHost     = "ServerHost"
+)
+
+/*
+DefaultConfig is the defaut configuration
+*/
+var DefaultConfig = map[string]interface{}{
+	ThreadPoolSize: 10,
+	FrameQueueSize: 10000,
+	ServerPort:     "9091",
+	ServerHost:     "127.0.0.1",
+}
+
+type consolelogger func(v ...interface{})
+
+/*
+Fatal/print logger methods. Using a custom type so we can test calls with unit
+tests.
+*/
+var fatal = consolelogger(log.Fatal)
+var print = consolelogger(func(a ...interface{}) {
+	fmt.Fprint(os.Stderr, a...)
+	fmt.Fprint(os.Stderr, "\n")
+})
+
+/*
+DudelDu server instance (used by unit tests)
+*/
+var dds *dudeldu.Server
+
+/*
+Main entry point for DudelDu.
+*/
+func main() {
+	var err error
+	var plf dudeldu.PlaylistFactory
+
+	print(fmt.Sprintf("DudelDu %v", dudeldu.ProductVersion))
+
+	auth := flag.String("auth", "", "Authentication as <user>:<pass>")
+	serverHost := flag.String("host", DefaultConfig[ServerHost].(string), "Server hostname to listen on")
+	serverPort := flag.String("port", DefaultConfig[ServerPort].(string), "Server port to listen on")
+	threadPoolSize := flag.Int("tps", DefaultConfig[ThreadPoolSize].(int), "Thread pool size")
+	frameQueueSize := flag.Int("fqs", DefaultConfig[FrameQueueSize].(int), "Frame queue size")
+	enableDebug := flag.Bool("debug", false, "Enable extra debugging output")
+	loopPlaylist := flag.Bool("loop", false, "Loop playlists")
+	shufflePlaylist := flag.Bool("shuffle", false, "Shuffle playlists")
+	showHelp := flag.Bool("?", false, "Show this help message")
+
+	flag.Usage = func() {
+		print(fmt.Sprintf("Usage of %s [options] <playlist>", os.Args[0]))
+		flag.PrintDefaults()
+	}
+
+	flag.Parse()
+
+	if len(flag.Args()) != 1 || *showHelp {
+		flag.Usage()
+		return
+	}
+
+	dudeldu.DebugOutput = *enableDebug
+
+	laddr := fmt.Sprintf("%v:%v", *serverHost, *serverPort)
+
+	print(fmt.Sprintf("Serving playlist %v on %v", flag.Arg(0), laddr))
+	print(fmt.Sprintf("Thread pool size: %v", *threadPoolSize))
+	print(fmt.Sprintf("Frame queue size: %v", *frameQueueSize))
+	print(fmt.Sprintf("Loop playlist: %v", *loopPlaylist))
+	print(fmt.Sprintf("Shuffle playlist: %v", *shufflePlaylist))
+	if *auth != "" {
+		print(fmt.Sprintf("Required authentication: %v", *auth))
+	}
+
+	// Create server and listen
+
+	plf, err = playlist.NewFilePlaylistFactory(flag.Arg(0))
+
+	if err == nil {
+
+		rh := dudeldu.NewDefaultRequestHandler(plf, *loopPlaylist, *shufflePlaylist, *auth)
+		dds = dudeldu.NewServer(rh.HandleRequest)
+
+		defer print("Shutting down")
+
+		err = dds.Run(laddr, nil)
+	}
+
+	if err != nil {
+		fatal(err)
+	}
+}

+ 225 - 0
server/dudeldu_test.go

@@ -0,0 +1,225 @@
+/*
+ * DudelDu
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the MIT
+ * License, If a copy of the MIT License was not distributed with this
+ * file, You can obtain one at https://opensource.org/licenses/MIT.
+ */
+
+package main
+
+import (
+	"bytes"
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"os"
+	"testing"
+
+	"devt.de/krotik/common/fileutil"
+	"devt.de/krotik/common/testutil"
+	"devt.de/krotik/dudeldu"
+	"devt.de/krotik/dudeldu/playlist"
+)
+
+const testFilePlaylist = `
+/*
+Test comment
+*/
+{
+	"/testpath" : [
+		{
+			"artist" : "artist1",  // 1234
+			"title"  : "test1",
+			"path"   : "playlisttest/test1.mp3"
+		},
+		{
+			"artist" : "artist2",
+			"title"  : "test2",
+			"path"   : "playlisttest/test2.mp4"
+		},
+		{
+			"artist" : "artist3",
+			"title"  : "test3",
+			"path"   : "playlisttest/test3.mp3"
+		}
+	]
+}`
+
+const pdir = "playlisttest"
+
+func TestRequestHandlerFilePlaylist(t *testing.T) {
+
+	var out bytes.Buffer
+
+	// Collect the print output
+	dudeldu.Print = func(v ...interface{}) {
+		out.WriteString(fmt.Sprint(v...))
+		out.WriteString("\n")
+	}
+	defer func() {
+		dudeldu.Print = log.Print
+	}()
+
+	os.Mkdir(pdir, 0770)
+	defer func() {
+		os.RemoveAll(pdir)
+	}()
+
+	ioutil.WriteFile(pdir+"/test.dpl", []byte(testFilePlaylist), 0644)
+	ioutil.WriteFile(pdir+"/test1.mp3", []byte("abcdefgh"), 0644)
+	ioutil.WriteFile(pdir+"/test2.mp4", []byte("12345"), 0644)
+	ioutil.WriteFile(pdir+"/test3.mp3", []byte("???!!!&&&$$$"), 0644)
+
+	fac, err := playlist.NewFilePlaylistFactory(pdir + "/test.dpl")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	drh := dudeldu.NewDefaultRequestHandler(fac, false, false, "")
+	testConn := &testutil.ErrorTestingConnection{}
+	dudeldu.MetaDataInterval = 5
+	playlist.FrameSize = 5
+
+	drh.ServeRequest(testConn, "/testpath", true, 2, "")
+
+	fmt.Println(out.String())
+
+	if testConn.Out.String() != ("ICY 200 OK\r\n" +
+		"Content-Type: audio/mpeg\r\n" +
+		"icy-name: /testpath\r\n" +
+		"icy-metadata: 1\r\n" +
+		"icy-metaint: 5\r\n" +
+		"\r\n" +
+		`cdefg` + string(0x02) + `StreamTitle='test2 - artist2';` + string([]byte{0x0, 0x0}) +
+		`h1234` + string(0x02) + `StreamTitle='test3 - artist3';` + string([]byte{0x0, 0x0}) +
+		`5???!` + string(0x02) + `StreamTitle='test3 - artist3';` + string([]byte{0x0, 0x0}) +
+		`!!&&&` + string(0x02) + `StreamTitle='test3 - artist3';` + string([]byte{0x0, 0x0}) +
+		`$$$`) {
+
+		t.Error("Unexpected response:", testConn.Out.String())
+		return
+	}
+
+}
+
+func TestDudelDuMain(t *testing.T) {
+
+	// Make the fatal a simple print
+
+	fatal = print
+
+	// Make sure out.txt and test.dpl are removed
+
+	defer func() {
+		if res, _ := fileutil.PathExists("out.txt"); res {
+			os.Remove("out.txt")
+		}
+		if res, _ := fileutil.PathExists("test.dpl"); res {
+			os.Remove("test.dpl")
+		}
+	}()
+
+	// Reset flags
+
+	flag.CommandLine = &flag.FlagSet{}
+
+	// Test usage text
+
+	os.Args = []string{"dudeldu", "-?", "-port", "9000", "test"}
+	flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
+
+	if ret, err := execMain(); err != nil || ret != `
+DudelDu `[1:]+dudeldu.ProductVersion+`
+Usage of dudeldu [options] <playlist>
+  -?	Show this help message
+  -auth string
+    	Authentication as <user>:<pass>
+  -debug
+    	Enable extra debugging output
+  -fqs int
+    	Frame queue size (default 10000)
+  -host string
+    	Server hostname to listen on (default "127.0.0.1")
+  -loop
+    	Loop playlists
+  -port string
+    	Server port to listen on (default "9091")
+  -shuffle
+    	Shuffle playlists
+  -tps int
+    	Thread pool size (default 10)
+` {
+		t.Error("Unexpected output:", ret, err)
+		return
+	}
+
+	ioutil.WriteFile("test.dpl", []byte("{}"), 0644)
+
+	os.Args = []string{"dudeldu", "-auth", "web:web", "-port", "-1", "test.dpl"}
+	flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
+
+	if ret, err := execMain(); err != nil || ret != `
+DudelDu `[1:]+dudeldu.ProductVersion+`
+Serving playlist test.dpl on 127.0.0.1:-1
+Thread pool size: 10
+Frame queue size: 10000
+Loop playlist: false
+Shuffle playlist: false
+Required authentication: web:web
+listen tcp: invalid port -1
+Shutting down
+` && ret != `
+DudelDu `[1:]+dudeldu.ProductVersion+`
+Serving playlist test.dpl on 127.0.0.1:-1
+Thread pool size: 10
+Frame queue size: 10000
+Loop playlist: false
+Shuffle playlist: false
+Required authentication: web:web
+listen tcp: address -1: invalid port
+Shutting down
+` {
+		t.Error("Unexpected output:", ret, err)
+		return
+	}
+}
+
+/*
+Execute the main function and capture the output.
+*/
+func execMain() (string, error) {
+
+	// Exchange stderr to a file
+
+	origStdErr := os.Stderr
+	outFile, err := os.Create("out.txt")
+	if err != nil {
+		return "", err
+	}
+	defer func() {
+		outFile.Close()
+		os.RemoveAll("out.txt")
+
+		// Put Stderr back
+
+		os.Stderr = origStdErr
+	}()
+
+	os.Stderr = outFile
+
+	main()
+
+	outFile.Sync()
+
+	out, err := ioutil.ReadFile("out.txt")
+	if err != nil {
+		return "", err
+	}
+
+	return string(out), nil
+}

+ 104 - 0
server_test.go

@@ -0,0 +1,104 @@
+/*
+ * DudelDu
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the MIT
+ * License, If a copy of the MIT License was not distributed with this
+ * file, You can obtain one at https://opensource.org/licenses/MIT.
+ */
+
+package dudeldu
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"sync"
+	"testing"
+)
+
+var testport = "localhost:9090"
+
+func TestServer(t *testing.T) {
+
+	DebugOutput = true
+
+	var out bytes.Buffer
+
+	// Collect the print output
+	Print = func(v ...interface{}) {
+		out.WriteString(fmt.Sprint(v...))
+		out.WriteString("\n")
+	}
+	defer func() {
+		Print = log.Print
+	}()
+
+	dds := NewServer(func(c net.Conn, err net.Error) {
+		if err != nil {
+			t.Error(err)
+			return
+		}
+
+		c.Write([]byte("Hello"))
+
+		c.Close()
+	})
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+
+	err := dds.Run(":abc", &wg)
+	if err == nil {
+		t.Error("Unexpected error return:", err)
+		return
+	}
+
+	wg.Add(1)
+
+	go func() {
+		err := dds.Run(testport, &wg)
+		if err != nil {
+			t.Error(err)
+			return
+		}
+	}()
+
+	wg.Wait()
+
+	// Server is now running
+
+	ret, err := readSocket()
+
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	if ret != "Hello" {
+		t.Error("Unexpected server response:", ret)
+		return
+	}
+
+	wg.Add(1)
+
+	dds.Shutdown()
+
+	wg.Wait()
+}
+
+func readSocket() (string, error) {
+	conn, err := net.Dial("tcp", testport)
+	if err != nil {
+		return "", err
+	}
+	defer conn.Close()
+
+	var buf bytes.Buffer
+	io.Copy(&buf, conn)
+
+	return buf.String(), nil
+}