├── .circleci └── config.yml ├── .github └── workflows │ └── docker-publish.yml ├── Dockerfile ├── LICENSE ├── README.rst ├── TODO ├── data └── VGC Sonic.png ├── dlna ├── dlna.go ├── dlna_test.go └── dms │ ├── cd-service-desc.go │ ├── cds.go │ ├── cds_test.go │ ├── cm-service-desc.go │ ├── cms.go │ ├── dms.go │ ├── dms_others.go │ ├── dms_test.go │ ├── dms_unix.go │ ├── dms_unix_test.go │ ├── dms_windows.go │ ├── ffmpeg.go │ ├── html.go │ ├── mimetype.go │ ├── mrrs-desc.go │ └── mrrs.go ├── go.mod ├── go.sum ├── helpers ├── bsd │ └── dms └── systemd │ └── dms.service ├── main.go ├── misc ├── dms-win32 │ ├── NOTES │ ├── README.txt │ └── dms-gui.bat ├── misc.go └── misc_test.go ├── play ├── attrs.go ├── bool.go ├── browse.xml ├── closure.go ├── execbug.go ├── execgood.go ├── ffprobe.go ├── getsortcaps.xml ├── mime.go ├── parse_http_version.go ├── print-ifs.go ├── scpd.go ├── soap.go ├── termsig │ └── main.go ├── transcode.go └── url.go ├── rrcache └── rrcache.go ├── soap └── soap.go ├── ssdp └── ssdp.go ├── transcode └── transcode.go ├── upnp ├── eventing.go ├── eventing_test.go └── upnp.go └── upnpav └── upnpav.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | machine: true 5 | environment: 6 | GO_BRANCH: release-branch.go1.16 7 | steps: 8 | - run: echo $CIRCLE_WORKING_DIRECTORY 9 | - run: echo $PWD 10 | - run: echo $GOPATH 11 | - run: echo 'export GOPATH=$HOME/go' >> $BASH_ENV 12 | - run: echo 'export PATH="$GOPATH/bin:$PATH"' >> $BASH_ENV 13 | - run: echo $GOPATH 14 | - run: which go 15 | - run: go version 16 | - run: | 17 | cd /usr/local 18 | sudo mkdir go.local 19 | sudo chown `whoami` go.local 20 | - restore_cache: 21 | key: go-local- 22 | - run: | 23 | cd /usr/local 24 | git clone git://github.com/golang/go go.local || true 25 | cd go.local 26 | git fetch 27 | git checkout "$GO_BRANCH" 28 | [[ -x bin/go && `git rev-parse HEAD` == `cat anacrolix.built` ]] && exit 29 | cd src 30 | ./make.bash || exit 31 | git rev-parse HEAD > ../anacrolix.built 32 | - save_cache: 33 | paths: /usr/local/go.local 34 | key: go-local-{{ checksum "/usr/local/go.local/anacrolix.built" }} 35 | - run: echo 'export PATH="/usr/local/go.local/bin:$PATH"' >> $BASH_ENV 36 | - run: go version 37 | - checkout 38 | - restore_cache: 39 | keys: 40 | - go-pkg- 41 | - restore_cache: 42 | keys: 43 | - go-cache- 44 | - run: go mod download 45 | - run: go test -v -race ./... -count 2 -bench . 46 | - run: go test -bench . ./... 47 | - run: set +e; CGO_ENABLED=0 go test -v ./...; true 48 | - run: GOARCH=386 go test ./... -count 2 -bench . 49 | - save_cache: 50 | key: go-pkg-{{ checksum "go.mod" }} 51 | paths: 52 | - ~/go/pkg 53 | - save_cache: 54 | key: go-cache-{{ .Revision }} 55 | paths: 56 | - ~/.cache/go-build 57 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish Container Images 2 | 3 | on: push 4 | 5 | jobs: 6 | build-container-images: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | packages: write 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup QEMU 17 | uses: docker/setup-qemu-action@v2 18 | 19 | - name: Setup Docker Buildx 20 | uses: docker/setup-buildx-action@v3 21 | 22 | - name: Login to GitHub Container Registry 23 | uses: docker/login-action@v2 24 | with: 25 | registry: ghcr.io 26 | username: ${{ github.actor }} 27 | password: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Build and push 30 | uses: docker/build-push-action@v2 31 | with: 32 | context: . 33 | platforms: linux/amd64,linux/arm64 34 | push: ${{ github.event_name != 'pull_request' }} 35 | tags: | 36 | ghcr.io/${{ github.repository }}:latest 37 | labels: ${{ steps.meta.outputs.labels }} 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/alpine:edge AS build 2 | WORKDIR /dms 3 | ADD . /dms 4 | RUN apk add --no-cache go gcc musl-dev 5 | RUN go mod tidy 6 | RUN go build -trimpath -buildmode=pie -ldflags="-s -w" -o dms 7 | 8 | 9 | FROM docker.io/alpine:edge 10 | COPY --from=build --chown=1000:1000 /dms/dms /dms 11 | RUN apk add --no-cache ffmpeg ffmpegthumbnailer mailcap 12 | RUN adduser user || true 13 | USER user:user 14 | WORKDIR /dmsdir 15 | VOLUME /dmsdir 16 | ENTRYPOINT ["/dms"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Matt Joiner . 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Funding 2 | ======= 3 | 4 | dms is looking for funding for further development. See https://github.com/sponsors/anacrolix and the issues list if you have something specific in mind. Thank you for your support! 5 | 6 | dms 7 | === 8 | 9 | dms is a UPnP DLNA Digital Media Server. It runs from the terminal, and serves 10 | content directly from the filesystem from the working directory, or the path 11 | given. The SSDP component will broadcast and respond to requests on all 12 | available network interfaces. 13 | 14 | dms advertises and serves the raw files, in addition to alternate transcoded 15 | streams when it's able, such as mpeg2 PAL-DVD and WebM for the Chromecast. It 16 | will also provide thumbnails where possible. 17 | 18 | dms also supports serving dynamic streams (e.g. a live rtsp stream) generated 19 | on the fly with the help of an external application (e.g. ffmpeg). 20 | 21 | dms uses ``ffprobe``/``avprobe`` to get media data such as bitrate and duration, ``ffmpeg``/``avconv`` for video transoding, and ``ffmpegthumbnailer`` for generating thumbnails when browsing. These commands must be in the ``PATH`` given to ``dms`` or the features requiring them will be disabled. 22 | 23 | .. image:: https://i.imgur.com/qbHilI7.png 24 | 25 | .. image:: https://raw.githubusercontent.com/anacrolix/dms/f9fb798ec360c2d2c11ba3071b95efbeddca2c02/dms-8player.png 26 | 27 | Installing 28 | ========== 29 | 30 | Assuming ``$GOPATH`` and Go have been configured already:: 31 | 32 | $ go install github.com/anacrolix/dms@latest 33 | 34 | Ensure ``ffmpeg``/``avconv`` and/or ``ffmpegthumbnailer`` are in the ``PATH`` if the features depending on them are desired. 35 | 36 | To run:: 37 | 38 | $ "$GOPATH"/bin/dms 39 | 40 | Running DMS using Docker 41 | ======================== 42 | 43 | `dms` is distributed as Docker Image. Serve Media in `/mediadirectory` using `dms`: 44 | 45 | .. code-block:: bash 46 | 47 | docker pull ghcr.io/anacrolix/dms:latest 48 | docker run -d --network host -v /mediadirectory:/dmsdir ghcr.io/anacrolix/dms:latest 49 | 50 | Running DMS as a systemd service 51 | ================================= 52 | 53 | A sample systemd `.service` file has been `provided `_ to assist in running DMS as a system service. 54 | 55 | Running DMS as a FreeBSD service 56 | ================================ 57 | 58 | Install the `provided `_ service file to /etc/rc.d or /usr/local/etc/rc.d 59 | add ``dms_enable="YES"``, and optionally ``dms_root="/path/to/my/media"`` and ``dms_user="myuser"`` to your /etc/rc.conf 60 | 61 | Known Compatible Players and Renderers 62 | ====================================== 63 | 64 | * Probably all Panasonic Viera TVs. 65 | * Android's BubbleUPnP and AirWire 66 | * Chromecast 67 | * VLC 68 | * LG Smart TVs, with varying success. 69 | * Roku devices 70 | * Apple TV 4K via VLC and 8player 71 | * iOS VLC and 8player 72 | 73 | 74 | Usage of dms: 75 | ===================== 76 | 77 | .. list-table:: Usage 78 | :widths: auto 79 | :header-rows: 1 80 | 81 | * - parameter 82 | - description 83 | * - ``-allowDynamicStreams`` 84 | - turns on support for `.dms.json` files in the path 85 | * - ``-allowedIps string`` 86 | - allowed ip of clients, separated by comma 87 | * - ``-config string`` 88 | - json configuration file 89 | * - ``-deviceIcon string`` 90 | - device icon 91 | * - ``-deviceIconSizes string`` 92 | - device icon sizes, separated by comma 93 | * - ``-fFprobeCachePath string`` 94 | - path to FFprobe cache file (default "/home/efreak/.dms-ffprobe-cache") 95 | * - ``-forceTranscodeTo string`` 96 | - force transcoding to certain format, supported: 'chromecast', 'vp8' 97 | * - ``-friendlyName string`` 98 | - server friendly name 99 | * - ``-http string`` 100 | - http server port (default ":1338") 101 | * - ``-ifname string`` 102 | - specific SSDP network interface 103 | * - ``-ignoreHidden`` 104 | - ignore hidden files and directories 105 | * - ``-ignoreUnreadable`` 106 | - ignore unreadable files and directories 107 | * - ``-ignore`` 108 | - ignore comma separated list of paths (i.e. -ignore thumbnails,thumbs) 109 | * - ``-logHeaders`` 110 | - log HTTP headers 111 | * - ``-noProbe`` 112 | - disable media probing with ffprobe 113 | * - ``-noTranscode`` 114 | - disable transcoding 115 | * - ``-notifyInterval duration`` 116 | - interval between SSPD announces (default 30s) 117 | * - ``-path string`` 118 | - browse root path 119 | * - ``-stallEventSubscribe`` 120 | - workaround for some bad event subscribers 121 | * - ``-transcodeLogPattern`` 122 | - pattern where to write transcode logs to. The ``[tsname]`` placeholder is replaced with the name of the item currently being played. The default is ``$HOME/.dms/log/[tsname]``. You may turn off transcode logging entirely by setting it to ``/dev/null``. You may log to stderr by setting ``/dev/stderr``. 123 | 124 | An example json configuration file:: 125 | 126 | { 127 | "path": "/path/to/media/files", 128 | "friendlyName": "dms", 129 | "noTranscode": true, 130 | "deviceIcon": "/path/to/icon.png", 131 | "deviceIconSizes": ["48:512","128:512"] 132 | } 133 | 134 | Dynamic streams 135 | =============== 136 | DMS supports "dynamic streams" generated on the fly. This feature can be activated with the 137 | ``-allowDynamicStreams`` command line flag and can be configured by placing special metadata 138 | files in your content directory. 139 | The name of these metadata files ends with ``.dms.json``, their structure is `documented here `_. 140 | 141 | An example:: 142 | 143 | { 144 | "Title": "My awesome webcam", 145 | "Resources": [ 146 | { 147 | "MimeType": "video/webm", 148 | "Command": "ffmpeg -i rtsp://10.6.8.161:554/Streaming/Channels/502/ -c:v copy -c:a copy -movflags +faststart+frag_keyframe+empty_moov -f matroska -" 149 | } 150 | ] 151 | } 152 | 153 | By default, dynamic content is treated as video. It is possible to specify a "Type" parameter with value "audio" or "video" to explicitly set this. 154 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * Reintegrate ffprobe error suppression into the ffmpeg.Probe function 2 | * Replace panics with proper error handling throughout the codebase. 3 | * Move ./dlna/dms somewhere more appropriate. It's moreof a DMS than a DLNADMS now. 4 | * Fix seeking for transcodes. Should be broken. 5 | * DMS handler path /icon should be /thumbnail, and /deviceIcon->/icon, or something like that. 6 | * Work around lack of ffmpegthumbnailer on Windows. 7 | -------------------------------------------------------------------------------- /data/VGC Sonic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anacrolix/dms/4f28a5e2ab128f14291996289044f8a140cfb311/data/VGC Sonic.png -------------------------------------------------------------------------------- /dlna/dlna.go: -------------------------------------------------------------------------------- 1 | package dlna 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | const ( 10 | TimeSeekRangeDomain = "TimeSeekRange.dlna.org" 11 | ContentFeaturesDomain = "contentFeatures.dlna.org" 12 | TransferModeDomain = "transferMode.dlna.org" 13 | ) 14 | 15 | type ContentFeatures struct { 16 | ProfileName string 17 | SupportTimeSeek bool 18 | SupportRange bool 19 | // Play speeds, DLNA.ORG_PS would go here if supported. 20 | Transcoded bool 21 | // DLNA.ORG_FLAGS go here if you need to tweak. 22 | Flags string 23 | } 24 | 25 | func BinaryInt(b bool) uint { 26 | if b { 27 | return 1 28 | } else { 29 | return 0 30 | } 31 | } 32 | 33 | // flags are in hex. trailing 24 zeroes, 26 are after the space 34 | // "DLNA.ORG_OP=" time-seek-range-supp bytes-range-header-supp 35 | func (cf ContentFeatures) String() (ret string) { 36 | // DLNA.ORG_PN=[a-zA-Z0-9_]* 37 | params := make([]string, 0, 3) 38 | if cf.ProfileName != "" { 39 | params = append(params, "DLNA.ORG_PN="+cf.ProfileName) 40 | } 41 | params = append(params, fmt.Sprintf( 42 | "DLNA.ORG_OP=%b%b;DLNA.ORG_CI=%b", 43 | BinaryInt(cf.SupportTimeSeek), 44 | BinaryInt(cf.SupportRange), 45 | BinaryInt(cf.Transcoded))) 46 | // https://stackoverflow.com/questions/29182754/c-dlna-generate-dlna-org-flags 47 | // DLNA_ORG_FLAG_STREAMING_TRANSFER_MODE | DLNA_ORG_FLAG_BACKGROUND_TRANSFERT_MODE | DLNA_ORG_FLAG_CONNECTION_STALL | DLNA_ORG_FLAG_DLNA_V15 48 | flags := "01700000000000000000000000000000" 49 | if cf.Flags != "" { 50 | flags = cf.Flags 51 | } 52 | params = append(params, "DLNA.ORG_FLAGS="+flags) 53 | return strings.Join(params, ";") 54 | } 55 | 56 | func ParseNPTTime(s string) (time.Duration, error) { 57 | var h, m, sec, ms time.Duration 58 | n, err := fmt.Sscanf(s, "%d:%2d:%2d.%3d", &h, &m, &sec, &ms) 59 | if err != nil { 60 | return -1, err 61 | } 62 | if n < 3 { 63 | return -1, fmt.Errorf("invalid npt time: %s", s) 64 | } 65 | ret := time.Duration(h) * time.Hour 66 | ret += time.Duration(m) * time.Minute 67 | ret += sec * time.Second 68 | ret += ms * time.Millisecond 69 | return ret, nil 70 | } 71 | 72 | func FormatNPTTime(npt time.Duration) string { 73 | npt /= time.Millisecond 74 | ms := npt % 1000 75 | npt /= 1000 76 | s := npt % 60 77 | npt /= 60 78 | m := npt % 60 79 | npt /= 60 80 | h := npt 81 | return fmt.Sprintf("%02d:%02d:%02d.%03d", h, m, s, ms) 82 | } 83 | 84 | type NPTRange struct { 85 | Start, End time.Duration 86 | } 87 | 88 | func ParseNPTRange(s string) (ret NPTRange, err error) { 89 | ss := strings.SplitN(s, "-", 2) 90 | if ss[0] != "" { 91 | ret.Start, err = ParseNPTTime(ss[0]) 92 | if err != nil { 93 | return 94 | } 95 | } 96 | if ss[1] != "" { 97 | ret.End, err = ParseNPTTime(ss[1]) 98 | if err != nil { 99 | return 100 | } 101 | } 102 | return 103 | } 104 | 105 | func (me NPTRange) String() (ret string) { 106 | ret = me.Start.String() + "-" 107 | if me.End >= 0 { 108 | ret += me.End.String() 109 | } 110 | return 111 | } 112 | -------------------------------------------------------------------------------- /dlna/dlna_test.go: -------------------------------------------------------------------------------- 1 | package dlna 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestContentFeaturesString(t *testing.T) { 8 | a := ContentFeatures{ 9 | Transcoded: true, 10 | SupportTimeSeek: true, 11 | }.String() 12 | e := "DLNA.ORG_OP=10;DLNA.ORG_CI=1;DLNA.ORG_FLAGS=01700000000000000000000000000000" 13 | if e != a { 14 | t.Fatal(a) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /dlna/dms/cd-service-desc.go: -------------------------------------------------------------------------------- 1 | package dms 2 | 3 | const contentDirectoryServiceDescription = ` 4 | 5 | 6 | 1 7 | 0 8 | 9 | 10 | 11 | GetSearchCapabilities 12 | 13 | 14 | SearchCaps 15 | out 16 | SearchCapabilities 17 | 18 | 19 | 20 | 21 | GetSortCapabilities 22 | 23 | 24 | SortCaps 25 | out 26 | SortCapabilities 27 | 28 | 29 | 30 | 31 | GetSortExtensionCapabilities 32 | 33 | 34 | SortExtensionCaps 35 | out 36 | SortExtensionCapabilities 37 | 38 | 39 | 40 | 41 | GetFeatureList 42 | 43 | 44 | FeatureList 45 | out 46 | FeatureList 47 | 48 | 49 | 50 | 51 | GetSystemUpdateID 52 | 53 | 54 | Id 55 | out 56 | SystemUpdateID 57 | 58 | 59 | 60 | 61 | Browse 62 | 63 | 64 | ObjectID 65 | in 66 | A_ARG_TYPE_ObjectID 67 | 68 | 69 | BrowseFlag 70 | in 71 | A_ARG_TYPE_BrowseFlag 72 | 73 | 74 | Filter 75 | in 76 | A_ARG_TYPE_Filter 77 | 78 | 79 | StartingIndex 80 | in 81 | A_ARG_TYPE_Index 82 | 83 | 84 | RequestedCount 85 | in 86 | A_ARG_TYPE_Count 87 | 88 | 89 | SortCriteria 90 | in 91 | A_ARG_TYPE_SortCriteria 92 | 93 | 94 | Result 95 | out 96 | A_ARG_TYPE_Result 97 | 98 | 99 | NumberReturned 100 | out 101 | A_ARG_TYPE_Count 102 | 103 | 104 | TotalMatches 105 | out 106 | A_ARG_TYPE_Count 107 | 108 | 109 | UpdateID 110 | out 111 | A_ARG_TYPE_UpdateID 112 | 113 | 114 | 115 | 116 | Search 117 | 118 | 119 | ContainerID 120 | in 121 | A_ARG_TYPE_ObjectID 122 | 123 | 124 | SearchCriteria 125 | in 126 | A_ARG_TYPE_SearchCriteria 127 | 128 | 129 | Filter 130 | in 131 | A_ARG_TYPE_Filter 132 | 133 | 134 | StartingIndex 135 | in 136 | A_ARG_TYPE_Index 137 | 138 | 139 | RequestedCount 140 | in 141 | A_ARG_TYPE_Count 142 | 143 | 144 | SortCriteria 145 | in 146 | A_ARG_TYPE_SortCriteria 147 | 148 | 149 | Result 150 | out 151 | A_ARG_TYPE_Result 152 | 153 | 154 | NumberReturned 155 | out 156 | A_ARG_TYPE_Count 157 | 158 | 159 | TotalMatches 160 | out 161 | A_ARG_TYPE_Count 162 | 163 | 164 | UpdateID 165 | out 166 | A_ARG_TYPE_UpdateID 167 | 168 | 169 | 170 | 171 | CreateObject 172 | 173 | 174 | ContainerID 175 | in 176 | A_ARG_TYPE_ObjectID 177 | 178 | 179 | Elements 180 | in 181 | A_ARG_TYPE_Result 182 | 183 | 184 | ObjectID 185 | out 186 | A_ARG_TYPE_ObjectID 187 | 188 | 189 | Result 190 | out 191 | A_ARG_TYPE_Result 192 | 193 | 194 | 195 | 196 | DestroyObject 197 | 198 | 199 | ObjectID 200 | in 201 | A_ARG_TYPE_ObjectID 202 | 203 | 204 | 205 | 206 | UpdateObject 207 | 208 | 209 | ObjectID 210 | in 211 | A_ARG_TYPE_ObjectID 212 | 213 | 214 | CurrentTagValue 215 | in 216 | A_ARG_TYPE_TagValueList 217 | 218 | 219 | NewTagValue 220 | in 221 | A_ARG_TYPE_TagValueList 222 | 223 | 224 | 225 | 226 | MoveObject 227 | 228 | 229 | ObjectID 230 | in 231 | A_ARG_TYPE_ObjectID 232 | 233 | 234 | NewParentID 235 | in 236 | A_ARG_TYPE_ObjectID 237 | 238 | 239 | NewObjectID 240 | out 241 | A_ARG_TYPE_ObjectID 242 | 243 | 244 | 245 | 246 | ImportResource 247 | 248 | 249 | SourceURI 250 | in 251 | A_ARG_TYPE_URI 252 | 253 | 254 | DestinationURI 255 | in 256 | A_ARG_TYPE_URI 257 | 258 | 259 | TransferID 260 | out 261 | A_ARG_TYPE_TransferID 262 | 263 | 264 | 265 | 266 | ExportResource 267 | 268 | 269 | SourceURI 270 | in 271 | A_ARG_TYPE_URI 272 | 273 | 274 | DestinationURI 275 | in 276 | A_ARG_TYPE_URI 277 | 278 | 279 | TransferID 280 | out 281 | A_ARG_TYPE_TransferID 282 | 283 | 284 | 285 | 286 | StopTransferResource 287 | 288 | 289 | TransferID 290 | in 291 | A_ARG_TYPE_TransferID 292 | 293 | 294 | 295 | 296 | DeleteResource 297 | 298 | 299 | ResourceURI 300 | in 301 | A_ARG_TYPE_URI 302 | 303 | 304 | 305 | 306 | GetTransferProgress 307 | 308 | 309 | TransferID 310 | in 311 | A_ARG_TYPE_TransferID 312 | 313 | 314 | TransferStatus 315 | out 316 | A_ARG_TYPE_TransferStatus 317 | 318 | 319 | TransferLength 320 | out 321 | A_ARG_TYPE_TransferLength 322 | 323 | 324 | TransferTotal 325 | out 326 | A_ARG_TYPE_TransferTotal 327 | 328 | 329 | 330 | 331 | CreateReference 332 | 333 | 334 | ContainerID 335 | in 336 | A_ARG_TYPE_ObjectID 337 | 338 | 339 | ObjectID 340 | in 341 | A_ARG_TYPE_ObjectID 342 | 343 | 344 | NewID 345 | out 346 | A_ARG_TYPE_ObjectID 347 | 348 | 349 | 350 | 351 | X_GetFeatureList 352 | 353 | 354 | FeatureList 355 | out 356 | A_ARG_TYPE_Featurelist 357 | 358 | 359 | 360 | 361 | X_SetBookmark 362 | 363 | 364 | CategoryType 365 | in 366 | A_ARG_TYPE_CategoryType 367 | 368 | 369 | RID 370 | in 371 | A_ARG_TYPE_RID 372 | 373 | 374 | ObjectID 375 | in 376 | A_ARG_TYPE_ObjectID 377 | 378 | 379 | PosSecond 380 | in 381 | A_ARG_TYPE_PosSec 382 | 383 | 384 | 385 | 386 | 387 | 388 | SearchCapabilities 389 | string 390 | 391 | 392 | SortCapabilities 393 | string 394 | 395 | 396 | SortExtensionCapabilities 397 | string 398 | 399 | 400 | SystemUpdateID 401 | ui4 402 | 403 | 404 | ContainerUpdateIDs 405 | string 406 | 407 | 408 | TransferIDs 409 | string 410 | 411 | 412 | FeatureList 413 | string 414 | 415 | 416 | A_ARG_TYPE_ObjectID 417 | string 418 | 419 | 420 | A_ARG_TYPE_Result 421 | string 422 | 423 | 424 | A_ARG_TYPE_SearchCriteria 425 | string 426 | 427 | 428 | A_ARG_TYPE_BrowseFlag 429 | string 430 | 431 | BrowseMetadata 432 | BrowseDirectChildren 433 | 434 | 435 | 436 | A_ARG_TYPE_Filter 437 | string 438 | 439 | 440 | A_ARG_TYPE_SortCriteria 441 | string 442 | 443 | 444 | A_ARG_TYPE_Index 445 | ui4 446 | 447 | 448 | A_ARG_TYPE_Count 449 | ui4 450 | 451 | 452 | A_ARG_TYPE_UpdateID 453 | ui4 454 | 455 | 456 | A_ARG_TYPE_TransferID 457 | ui4 458 | 459 | 460 | A_ARG_TYPE_TransferStatus 461 | string 462 | 463 | COMPLETED 464 | ERROR 465 | IN_PROGRESS 466 | STOPPED 467 | 468 | 469 | 470 | A_ARG_TYPE_TransferLength 471 | string 472 | 473 | 474 | A_ARG_TYPE_TransferTotal 475 | string 476 | 477 | 478 | A_ARG_TYPE_TagValueList 479 | string 480 | 481 | 482 | A_ARG_TYPE_URI 483 | uri 484 | 485 | 486 | A_ARG_TYPE_CategoryType 487 | ui4 488 | 489 | 490 | 491 | A_ARG_TYPE_RID 492 | ui4 493 | 494 | 495 | 496 | A_ARG_TYPE_PosSec 497 | ui4 498 | 499 | 500 | 501 | A_ARG_TYPE_Featurelist 502 | string 503 | 504 | 505 | 506 | ` 507 | -------------------------------------------------------------------------------- /dlna/dms/cds.go: -------------------------------------------------------------------------------- 1 | package dms 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "path" 12 | "path/filepath" 13 | "sort" 14 | "strconv" 15 | "strings" 16 | 17 | "github.com/anacrolix/ffprobe" 18 | "github.com/anacrolix/log" 19 | 20 | "github.com/anacrolix/dms/dlna" 21 | "github.com/anacrolix/dms/misc" 22 | "github.com/anacrolix/dms/upnp" 23 | "github.com/anacrolix/dms/upnpav" 24 | ) 25 | 26 | const dmsMetadataSuffix = ".dms.json" 27 | 28 | type contentDirectoryService struct { 29 | *Server 30 | upnp.Eventing 31 | } 32 | 33 | func (cds *contentDirectoryService) updateIDString() string { 34 | return fmt.Sprintf("%d", uint32(os.Getpid())) 35 | } 36 | 37 | type dmsDynamicStreamResource struct { 38 | // (optional) DLNA profile name to include in the response e.g. MPEG_PS_PAL 39 | DlnaProfileName string 40 | // (optional) DLNA.ORG_FLAGS if you need to override the default (8D500000000000000000000000000000) 41 | DlnaFlags string 42 | // required: mime type, e.g. video/mpeg 43 | MimeType string 44 | // (optional) resolution, e.g. 640x360 45 | Resolution string 46 | // (optional) bitrate, e.g. 721 47 | Bitrate uint 48 | // required: OS command to generate this resource on the fly 49 | Command string 50 | } 51 | 52 | type dmsDynamicMediaItem struct { 53 | // (optional) Title of this media item. Defaults to the filename, if omitted 54 | Title string 55 | // (optional) Type of media. Allowed values: "audio", "video". Defaults to video if omitted 56 | Type string 57 | // (optional) duration, e.g. 0:21:37.922 58 | Duration string 59 | // required: an array of available versions 60 | Resources []dmsDynamicStreamResource 61 | } 62 | 63 | func readDynamicStream(metadataPath string) (*dmsDynamicMediaItem, error) { 64 | bytes, err := ioutil.ReadFile(metadataPath) 65 | if err != nil { 66 | return nil, err 67 | } 68 | var re dmsDynamicMediaItem 69 | err = json.Unmarshal(bytes, &re) 70 | if err != nil { 71 | return nil, err 72 | } 73 | return &re, nil 74 | } 75 | 76 | func (me *contentDirectoryService) cdsObjectDynamicStreamToUpnpavObject(cdsObject object, fileInfo os.FileInfo, host, userAgent string) (ret interface{}, err error) { 77 | // at this point we know that entryFilePath points to a .dms.json file; slurp and parse 78 | dmsMediaItem, err := readDynamicStream(cdsObject.FilePath()) 79 | if err != nil { 80 | me.Logger.Printf("%s ignored: %v", cdsObject.FilePath(), err) 81 | return 82 | } 83 | 84 | obj := upnpav.Object{ 85 | ID: cdsObject.ID(), 86 | Restricted: 1, 87 | ParentID: cdsObject.ParentID(), 88 | } 89 | iconURI := (&url.URL{ 90 | Scheme: "http", 91 | Host: host, 92 | Path: iconPath, 93 | RawQuery: url.Values{ 94 | "path": {cdsObject.Path}, 95 | }.Encode(), 96 | }).String() 97 | obj.Icon = iconURI 98 | // TODO(anacrolix): This might not be necessary due to item res image 99 | // element. 100 | obj.AlbumArtURI = iconURI 101 | 102 | switch dmsMediaItem.Type { 103 | case "video": 104 | obj.Class = "object.item.videoItem" 105 | case "audio": 106 | obj.Class = "object.item.audioItem" 107 | default: 108 | obj.Class = "object.item.videoItem" 109 | } 110 | 111 | obj.Title = dmsMediaItem.Title 112 | if obj.Title == "" { 113 | obj.Title = strings.TrimSuffix(fileInfo.Name(), dmsMetadataSuffix) 114 | } 115 | obj.Date = upnpav.Timestamp{Time: fileInfo.ModTime()} 116 | 117 | item := upnpav.Item{ 118 | Object: obj, 119 | // Capacity: 1 for icon, plus resources. 120 | Res: make([]upnpav.Resource, 0, 1+len(dmsMediaItem.Resources)), 121 | } 122 | for i, dmsStream := range dmsMediaItem.Resources { 123 | // default flags borrowed from Serviio: DLNA_ORG_FLAG_SENDER_PACED | DLNA_ORG_FLAG_S0_INCREASE | DLNA_ORG_FLAG_SN_INCREASE | DLNA_ORG_FLAG_STREAMING_TRANSFER_MODE | DLNA_ORG_FLAG_BACKGROUND_TRANSFERT_MODE | DLNA_ORG_FLAG_DLNA_V15 124 | flags := "8D500000000000000000000000000000" 125 | if dmsStream.DlnaFlags != "" { 126 | flags = dmsStream.DlnaFlags 127 | } 128 | item.Res = append(item.Res, upnpav.Resource{ 129 | URL: (&url.URL{ 130 | Scheme: "http", 131 | Host: host, 132 | Path: resPath, 133 | RawQuery: url.Values{ 134 | "path": {cdsObject.Path}, 135 | "index": {strconv.Itoa(i)}, 136 | }.Encode(), 137 | }).String(), 138 | ProtocolInfo: fmt.Sprintf("http-get:*:%s:%s", dmsStream.MimeType, dlna.ContentFeatures{ 139 | ProfileName: dmsStream.DlnaProfileName, 140 | SupportRange: false, 141 | SupportTimeSeek: false, 142 | Transcoded: true, 143 | Flags: flags, 144 | }.String()), 145 | Bitrate: dmsStream.Bitrate, 146 | Duration: dmsMediaItem.Duration, 147 | Resolution: dmsStream.Resolution, 148 | }) 149 | } 150 | 151 | // and an icon 152 | item.Res = append(item.Res, upnpav.Resource{ 153 | URL: (&url.URL{ 154 | Scheme: "http", 155 | Host: host, 156 | Path: iconPath, 157 | RawQuery: url.Values{ 158 | "path": {cdsObject.Path}, 159 | "c": {"jpeg"}, 160 | }.Encode(), 161 | }).String(), 162 | ProtocolInfo: "http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_TN", 163 | }) 164 | 165 | ret = item 166 | return 167 | } 168 | 169 | // Turns the given entry and DMS host into a UPnP object. A nil object is 170 | // returned if the entry is not of interest. 171 | func (me *contentDirectoryService) cdsObjectToUpnpavObject( 172 | cdsObject object, 173 | fileInfo os.FileInfo, 174 | host, userAgent string, 175 | ) (ret interface{}, err error) { 176 | entryFilePath := cdsObject.FilePath() 177 | ignored, err := me.IgnorePath(entryFilePath) 178 | if err != nil { 179 | return 180 | } 181 | if ignored { 182 | return 183 | } 184 | isDmsMetadata := strings.HasSuffix(entryFilePath, dmsMetadataSuffix) 185 | if !fileInfo.IsDir() && me.AllowDynamicStreams && isDmsMetadata { 186 | return me.cdsObjectDynamicStreamToUpnpavObject(cdsObject, fileInfo, host, userAgent) 187 | } 188 | 189 | obj := upnpav.Object{ 190 | ID: cdsObject.ID(), 191 | Restricted: 1, 192 | ParentID: cdsObject.ParentID(), 193 | } 194 | if fileInfo.IsDir() { 195 | obj.Class = "object.container.storageFolder" 196 | obj.Title = fileInfo.Name() 197 | childCount := me.objectChildCount(cdsObject) 198 | if childCount != 0 { 199 | ret = upnpav.Container{Object: obj, ChildCount: childCount} 200 | } 201 | return 202 | } 203 | if !fileInfo.Mode().IsRegular() { 204 | me.Logger.Printf("%s ignored: non-regular file", cdsObject.FilePath()) 205 | return 206 | } 207 | mimeType, err := MimeTypeByPath(entryFilePath) 208 | if err != nil { 209 | return 210 | } 211 | if !mimeType.IsMedia() { 212 | if isDmsMetadata { 213 | me.Logger.Levelf( 214 | log.Debug, 215 | "ignored %q: enable support for dynamic streams via the -allowDynamicStreams command line flag", cdsObject.FilePath()) 216 | } else { 217 | me.Logger.Levelf(log.Debug, "ignored %q: non-media file (%s)", cdsObject.FilePath(), mimeType) 218 | } 219 | return 220 | } 221 | iconURI := (&url.URL{ 222 | Scheme: "http", 223 | Host: host, 224 | Path: iconPath, 225 | RawQuery: url.Values{ 226 | "path": {cdsObject.Path}, 227 | }.Encode(), 228 | }).String() 229 | obj.Icon = iconURI 230 | // TODO(anacrolix): This might not be necessary due to item res image 231 | // element. 232 | obj.AlbumArtURI = iconURI 233 | obj.Class = "object.item." + mimeType.Type() + "Item" 234 | var ( 235 | ffInfo *ffprobe.Info 236 | nativeBitrate uint 237 | resDuration string 238 | ) 239 | if !me.NoProbe { 240 | ffInfo, probeErr := me.ffmpegProbe(entryFilePath) 241 | switch probeErr { 242 | case nil: 243 | if ffInfo != nil { 244 | nativeBitrate, _ = ffInfo.Bitrate() 245 | if d, err := ffInfo.Duration(); err == nil { 246 | resDuration = misc.FormatDurationSexagesimal(d) 247 | } 248 | } 249 | case ffprobe.ExeNotFound: 250 | default: 251 | me.Logger.Printf("error probing %s: %s", entryFilePath, probeErr) 252 | } 253 | } 254 | if obj.Title == "" { 255 | obj.Title = fileInfo.Name() 256 | } 257 | resolution := func() string { 258 | if ffInfo != nil { 259 | for _, strm := range ffInfo.Streams { 260 | if strm["codec_type"] != "video" { 261 | continue 262 | } 263 | width := strm["width"] 264 | height := strm["height"] 265 | return fmt.Sprintf("%.0fx%.0f", width, height) 266 | } 267 | } 268 | return "" 269 | }() 270 | item := upnpav.Item{ 271 | Object: obj, 272 | // Capacity: 1 for raw, 1 for icon, plus transcodes. 273 | Res: make([]upnpav.Resource, 0, 2+len(transcodes)), 274 | } 275 | item.Res = append(item.Res, upnpav.Resource{ 276 | URL: (&url.URL{ 277 | Scheme: "http", 278 | Host: host, 279 | Path: resPath, 280 | RawQuery: url.Values{ 281 | "path": {cdsObject.Path}, 282 | }.Encode(), 283 | }).String(), 284 | ProtocolInfo: fmt.Sprintf("http-get:*:%s:%s", mimeType, dlna.ContentFeatures{ 285 | SupportRange: true, 286 | }.String()), 287 | Bitrate: nativeBitrate, 288 | Duration: resDuration, 289 | Size: uint64(fileInfo.Size()), 290 | Resolution: resolution, 291 | }) 292 | if mimeType.IsVideo() { 293 | if !me.NoTranscode { 294 | item.Res = append(item.Res, transcodeResources(host, cdsObject.Path, resolution, resDuration)...) 295 | } 296 | item.Res = append(item.Res, upnpav.Resource{ 297 | URL: (&url.URL{ 298 | Scheme: "http", 299 | Host: host, 300 | Path: subtitlePath, 301 | RawQuery: url.Values{ 302 | "path": {cdsObject.Path}, 303 | }.Encode(), 304 | }).String(), 305 | ProtocolInfo: "http-get:*:text/plain", 306 | }) 307 | } 308 | if mimeType.IsVideo() || mimeType.IsImage() { 309 | item.Res = append(item.Res, upnpav.Resource{ 310 | URL: (&url.URL{ 311 | Scheme: "http", 312 | Host: host, 313 | Path: iconPath, 314 | RawQuery: url.Values{ 315 | "path": {cdsObject.Path}, 316 | "c": {"jpeg"}, 317 | }.Encode(), 318 | }).String(), 319 | ProtocolInfo: "http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_TN", 320 | }) 321 | } 322 | ret = item 323 | return 324 | } 325 | 326 | // Returns all the upnpav objects in a directory. 327 | func (me *contentDirectoryService) readContainer( 328 | o object, 329 | host, userAgent string, 330 | ) (ret []interface{}, err error) { 331 | sfis := sortableFileInfoSlice{ 332 | // TODO(anacrolix): Dig up why this special cast was added. 333 | FoldersLast: strings.Contains(userAgent, `AwoX/1.1`), 334 | } 335 | sfis.fileInfoSlice, err = o.readDir() 336 | if err != nil { 337 | return 338 | } 339 | sort.Sort(sfis) 340 | for _, fi := range sfis.fileInfoSlice { 341 | child := object{path.Join(o.Path, fi.Name()), me.RootObjectPath} 342 | obj, err := me.cdsObjectToUpnpavObject(child, fi, host, userAgent) 343 | if err != nil { 344 | me.Logger.Printf("error with %s: %s", child.FilePath(), err) 345 | continue 346 | } 347 | if obj != nil { 348 | ret = append(ret, obj) 349 | } 350 | } 351 | return 352 | } 353 | 354 | type browse struct { 355 | ObjectID string 356 | BrowseFlag string 357 | Filter string 358 | StartingIndex int 359 | RequestedCount int 360 | } 361 | 362 | // ContentDirectory object from ObjectID. 363 | func (me *contentDirectoryService) objectFromID(id string) (o object, err error) { 364 | o.Path, err = url.QueryUnescape(id) 365 | if err != nil { 366 | return 367 | } 368 | if o.Path == "0" { 369 | o.Path = "/" 370 | } 371 | o.Path = path.Clean(o.Path) 372 | if !path.IsAbs(o.Path) { 373 | err = fmt.Errorf("bad ObjectID %v", o.Path) 374 | return 375 | } 376 | o.RootObjectPath = me.RootObjectPath 377 | return 378 | } 379 | 380 | func (me *contentDirectoryService) Handle(action string, argsXML []byte, r *http.Request) ([][2]string, error) { 381 | host := r.Host 382 | userAgent := r.UserAgent() 383 | switch action { 384 | case "GetSystemUpdateID": 385 | return [][2]string{ 386 | {"Id", me.updateIDString()}, 387 | }, nil 388 | case "GetSortCapabilities": 389 | return [][2]string{ 390 | {"SortCaps", "dc:title"}, 391 | }, nil 392 | case "Browse": 393 | var browse browse 394 | if err := xml.Unmarshal([]byte(argsXML), &browse); err != nil { 395 | return nil, err 396 | } 397 | obj, err := me.objectFromID(browse.ObjectID) 398 | if err != nil { 399 | return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, err.Error()) 400 | } 401 | switch browse.BrowseFlag { 402 | case "BrowseDirectChildren": 403 | var objs []interface{} 404 | if me.OnBrowseDirectChildren == nil { 405 | objs, err = me.readContainer(obj, host, userAgent) 406 | } else { 407 | objs, err = me.OnBrowseDirectChildren(obj.Path, obj.RootObjectPath, host, userAgent) 408 | } 409 | if err != nil { 410 | return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, err.Error()) 411 | } 412 | totalMatches := len(objs) 413 | objs = objs[func() (low int) { 414 | low = browse.StartingIndex 415 | if low > len(objs) { 416 | low = len(objs) 417 | } 418 | return 419 | }():] 420 | if browse.RequestedCount != 0 && int(browse.RequestedCount) < len(objs) { 421 | objs = objs[:browse.RequestedCount] 422 | } 423 | result, err := xml.Marshal(objs) 424 | if err != nil { 425 | return nil, err 426 | } 427 | return [][2]string{ 428 | {"Result", didl_lite(string(result))}, 429 | {"NumberReturned", fmt.Sprint(len(objs))}, 430 | {"TotalMatches", fmt.Sprint(totalMatches)}, 431 | {"UpdateID", me.updateIDString()}, 432 | }, nil 433 | case "BrowseMetadata": 434 | var ret interface{} 435 | var err error 436 | if me.OnBrowseMetadata == nil { 437 | var fileInfo os.FileInfo 438 | fileInfo, err = os.Stat(obj.FilePath()) 439 | if err != nil { 440 | if os.IsNotExist(err) { 441 | return nil, &upnp.Error{ 442 | Code: upnpav.NoSuchObjectErrorCode, 443 | Desc: err.Error(), 444 | } 445 | } 446 | return nil, err 447 | } 448 | ret, err = me.cdsObjectToUpnpavObject(obj, fileInfo, host, userAgent) 449 | } else { 450 | ret, err = me.OnBrowseMetadata(obj.Path, obj.RootObjectPath, host, userAgent) 451 | } 452 | if err != nil { 453 | return nil, err 454 | } 455 | buf, err := xml.Marshal(ret) 456 | if err != nil { 457 | return nil, err 458 | } 459 | return [][2]string{ 460 | {"Result", didl_lite(func() string { return string(buf) }())}, 461 | {"NumberReturned", "1"}, 462 | {"TotalMatches", "1"}, 463 | {"UpdateID", me.updateIDString()}, 464 | }, nil 465 | default: 466 | return nil, upnp.Errorf( 467 | upnp.ArgumentValueInvalidErrorCode, 468 | "unhandled browse flag: %v", 469 | browse.BrowseFlag, 470 | ) 471 | } 472 | case "GetSearchCapabilities": 473 | return [][2]string{ 474 | {"SearchCaps", ""}, 475 | }, nil 476 | // Samsung Extensions 477 | case "X_GetFeatureList": 478 | // TODO: make it dependable on model 479 | // https://github.com/1100101/minidlna/blob/ca6dbba18390ad6f8b8d7b7dbcf797dbfd95e2db/upnpsoap.c#L2153-L2199 480 | return [][2]string{ 481 | {"FeatureList", ` 482 | 483 | // "A" 484 | // "V" 485 | // "I" 486 | 487 | `}, 488 | }, nil 489 | case "X_SetBookmark": 490 | // just ignore 491 | return [][2]string{}, nil 492 | default: 493 | return nil, upnp.InvalidActionError 494 | } 495 | } 496 | 497 | // Represents a ContentDirectory object. 498 | type object struct { 499 | Path string // The cleaned, absolute path for the object relative to the server. 500 | RootObjectPath string 501 | } 502 | 503 | func (me *contentDirectoryService) isOfInterest( 504 | cdsObject object, 505 | fileInfo os.FileInfo, 506 | ) (ret bool, err error) { 507 | entryFilePath := cdsObject.FilePath() 508 | ignored, err := me.IgnorePath(entryFilePath) 509 | if err != nil { 510 | return 511 | } 512 | if ignored { 513 | return 514 | } 515 | isDmsMetadata := strings.HasSuffix(entryFilePath, dmsMetadataSuffix) 516 | if !fileInfo.IsDir() && me.AllowDynamicStreams && isDmsMetadata { 517 | return true, nil 518 | } 519 | 520 | if fileInfo.IsDir() { 521 | hasChildren, err := me.objectHasChildren(cdsObject, fileInfo) 522 | return hasChildren, err 523 | } 524 | if !fileInfo.Mode().IsRegular() { 525 | me.Logger.Printf("%s ignored: non-regular file", cdsObject.FilePath()) 526 | return 527 | } 528 | 529 | mimeType, err := MimeTypeByPath(entryFilePath) 530 | if err != nil { 531 | return 532 | } 533 | 534 | if !mimeType.IsMedia() { 535 | return 536 | } 537 | return true, nil 538 | } 539 | 540 | // Returns the number of children this object has, such as for a container. 541 | func (cds *contentDirectoryService) objectChildCount(me object) (count int) { 542 | fileInfoSlice, err := me.readDir() 543 | if err != nil { 544 | return 545 | } 546 | for _, fi := range fileInfoSlice { 547 | child := object{path.Join(me.Path, fi.Name()), cds.RootObjectPath} 548 | isChild, err := cds.isOfInterest(child, fi) 549 | if err != nil { 550 | cds.Logger.Printf("error with %s: %s", child.FilePath(), err) 551 | continue 552 | } 553 | 554 | if isChild { 555 | count++ 556 | } 557 | } 558 | return 559 | } 560 | 561 | // Returns true if a recursive search for playable items in the provided 562 | // directory succeeds. Returns true on first hit. 563 | func (me *contentDirectoryService) objectHasChildren( 564 | cdsObject object, 565 | fileInfo os.FileInfo, 566 | ) (ret bool, err error) { 567 | if !fileInfo.IsDir() { 568 | panic("Expected directory") 569 | } 570 | 571 | files, err := cdsObject.readDir() 572 | if err != nil { 573 | return 574 | } 575 | for _, fi := range files { 576 | child := object{path.Join(cdsObject.Path, fi.Name()), me.RootObjectPath} 577 | isCdsObj, err := me.isOfInterest(child, fi) 578 | if err != nil { 579 | return false, err 580 | } 581 | if isCdsObj { 582 | // Return on first hit. We don't want a full library scan. 583 | return true, nil 584 | } 585 | } 586 | return 587 | } 588 | 589 | // Returns the actual local filesystem path for the object. 590 | func (o *object) FilePath() string { 591 | return filepath.Join(o.RootObjectPath, filepath.FromSlash(o.Path)) 592 | } 593 | 594 | // Returns the ObjectID for the object. This is used in various ContentDirectory actions. 595 | func (o object) ID() string { 596 | if !path.IsAbs(o.Path) { 597 | log.Panicf("Relative object path: %s", o.Path) 598 | } 599 | if len(o.Path) == 1 { 600 | return "0" 601 | } 602 | return url.QueryEscape(o.Path) 603 | } 604 | 605 | func (o *object) IsRoot() bool { 606 | return o.Path == "/" 607 | } 608 | 609 | // Returns the object's parent ObjectID. Fortunately it can be deduced from the 610 | // ObjectID (for now). 611 | func (o object) ParentID() string { 612 | if o.IsRoot() { 613 | return "-1" 614 | } 615 | o.Path = path.Dir(o.Path) 616 | return o.ID() 617 | } 618 | 619 | // This function exists rather than just calling os.(*File).Readdir because I 620 | // want to stat(), not lstat() each entry. 621 | func (o *object) readDir() (fis []os.FileInfo, err error) { 622 | dirPath := o.FilePath() 623 | dirFile, err := os.Open(dirPath) 624 | if err != nil { 625 | return 626 | } 627 | defer dirFile.Close() 628 | var dirContent []string 629 | dirContent, err = dirFile.Readdirnames(-1) 630 | if err != nil { 631 | return 632 | } 633 | fis = make([]os.FileInfo, 0, len(dirContent)) 634 | for _, file := range dirContent { 635 | fi, err := os.Stat(filepath.Join(dirPath, file)) 636 | if err != nil { 637 | continue 638 | } 639 | fis = append(fis, fi) 640 | } 641 | return 642 | } 643 | 644 | type sortableFileInfoSlice struct { 645 | fileInfoSlice []os.FileInfo 646 | FoldersLast bool 647 | } 648 | 649 | func (me sortableFileInfoSlice) Len() int { 650 | return len(me.fileInfoSlice) 651 | } 652 | 653 | func (me sortableFileInfoSlice) Less(i, j int) bool { 654 | if me.fileInfoSlice[i].IsDir() && !me.fileInfoSlice[j].IsDir() { 655 | return !me.FoldersLast 656 | } 657 | if !me.fileInfoSlice[i].IsDir() && me.fileInfoSlice[j].IsDir() { 658 | return me.FoldersLast 659 | } 660 | return strings.ToLower(me.fileInfoSlice[i].Name()) < strings.ToLower(me.fileInfoSlice[j].Name()) 661 | } 662 | 663 | func (me sortableFileInfoSlice) Swap(i, j int) { 664 | me.fileInfoSlice[i], me.fileInfoSlice[j] = me.fileInfoSlice[j], me.fileInfoSlice[i] 665 | } 666 | -------------------------------------------------------------------------------- /dlna/dms/cds_test.go: -------------------------------------------------------------------------------- 1 | package dms 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestEscapeObjectID(t *testing.T) { 9 | o := object{ 10 | Path: "/some/file", 11 | } 12 | id := o.ID() 13 | if strings.ContainsAny(id, "/") { 14 | t.Skip("may not work with some players: object IDs contain '/'") 15 | } 16 | } 17 | 18 | func TestRootObjectID(t *testing.T) { 19 | if (object{Path: "/"}).ID() != "0" { 20 | t.FailNow() 21 | } 22 | } 23 | 24 | func TestRootParentObjectID(t *testing.T) { 25 | if (object{Path: "/"}).ParentID() != "-1" { 26 | t.FailNow() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /dlna/dms/cm-service-desc.go: -------------------------------------------------------------------------------- 1 | package dms 2 | 3 | const connectionManagerServiceDescription = ` 4 | 5 | 6 | 1 7 | 0 8 | 9 | 10 | 11 | GetProtocolInfo 12 | 13 | 14 | Source 15 | out 16 | SourceProtocolInfo 17 | 18 | 19 | Sink 20 | out 21 | SinkProtocolInfo 22 | 23 | 24 | 25 | 26 | PrepareForConnection 27 | 28 | 29 | RemoteProtocolInfo 30 | in 31 | A_ARG_TYPE_ProtocolInfo 32 | 33 | 34 | PeerConnectionManager 35 | in 36 | A_ARG_TYPE_ConnectionManager 37 | 38 | 39 | PeerConnectionID 40 | in 41 | A_ARG_TYPE_ConnectionID 42 | 43 | 44 | Direction 45 | in 46 | A_ARG_TYPE_Direction 47 | 48 | 49 | ConnectionID 50 | out 51 | A_ARG_TYPE_ConnectionID 52 | 53 | 54 | AVTransportID 55 | out 56 | A_ARG_TYPE_AVTransportID 57 | 58 | 59 | RcsID 60 | out 61 | A_ARG_TYPE_RcsID 62 | 63 | 64 | 65 | 66 | ConnectionComplete 67 | 68 | 69 | ConnectionID 70 | in 71 | A_ARG_TYPE_ConnectionID 72 | 73 | 74 | 75 | 76 | GetCurrentConnectionIDs 77 | 78 | 79 | ConnectionIDs 80 | out 81 | CurrentConnectionIDs 82 | 83 | 84 | 85 | 86 | GetCurrentConnectionInfo 87 | 88 | 89 | ConnectionID 90 | in 91 | A_ARG_TYPE_ConnectionID 92 | 93 | 94 | RcsID 95 | out 96 | A_ARG_TYPE_RcsID 97 | 98 | 99 | AVTransportID 100 | out 101 | A_ARG_TYPE_AVTransportID 102 | 103 | 104 | ProtocolInfo 105 | out 106 | A_ARG_TYPE_ProtocolInfo 107 | 108 | 109 | PeerConnectionManager 110 | out 111 | A_ARG_TYPE_ConnectionManager 112 | 113 | 114 | PeerConnectionID 115 | out 116 | A_ARG_TYPE_ConnectionID 117 | 118 | 119 | Direction 120 | out 121 | A_ARG_TYPE_Direction 122 | 123 | 124 | Status 125 | out 126 | A_ARG_TYPE_ConnectionStatus 127 | 128 | 129 | 130 | 131 | 132 | 133 | SourceProtocolInfo 134 | string 135 | 136 | 137 | SinkProtocolInfo 138 | string 139 | 140 | 141 | CurrentConnectionIDs 142 | string 143 | 144 | 145 | A_ARG_TYPE_ConnectionStatus 146 | string 147 | 148 | OK 149 | ContentFormatMismatch 150 | InsufficientBandwidth 151 | UnreliableChannel 152 | Unknown 153 | 154 | 155 | 156 | A_ARG_TYPE_ConnectionManager 157 | string 158 | 159 | 160 | A_ARG_TYPE_Direction 161 | string 162 | 163 | Input 164 | Output 165 | 166 | 167 | 168 | A_ARG_TYPE_ProtocolInfo 169 | string 170 | 171 | 172 | A_ARG_TYPE_ConnectionID 173 | i4 174 | 175 | 176 | A_ARG_TYPE_AVTransportID 177 | i4 178 | 179 | 180 | A_ARG_TYPE_RcsID 181 | i4 182 | 183 | 184 | 185 | ` 186 | -------------------------------------------------------------------------------- /dlna/dms/cms.go: -------------------------------------------------------------------------------- 1 | package dms 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/anacrolix/dms/upnp" 7 | ) 8 | 9 | // const defaultProtocolInfo = "http-get:*:video/mpeg:*,http-get:*:video/mp4:*,http-get:*:video/vnd.dlna.mpeg-tts:*,http-get:*:video/avi:*,http-get:*:video/x-matroska:*,http-get:*:video/x-ms-wmv:*,http-get:*:video/wtv:*,http-get:*:audio/mpeg:*,http-get:*:audio/mp3:*,http-get:*:audio/mp4:*,http-get:*:audio/x-ms-wma*,http-get:*:audio/wav:*,http-get:*:audio/L16:*,http-get:*image/jpeg:*,http-get:*image/png:*,http-get:*image/gif:*,http-get:*image/tiff:*" 10 | const defaultProtocolInfo = "http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_TN,http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_SM,http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_MED,http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_LRG,http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_RES_H_V,http-get:*:image/png:DLNA.ORG_PN=PNG_TN,http-get:*:image/png:DLNA.ORG_PN=PNG_LRG,http-get:*:image/gif:DLNA.ORG_PN=GIF_LRG,http-get:*:audio/mpeg:DLNA.ORG_PN=MP3,http-get:*:audio/L16:DLNA.ORG_PN=LPCM,http-get:*:video/mpeg:DLNA.ORG_PN=AVC_TS_HD_24_AC3_ISO;SONY.COM_PN=AVC_TS_HD_24_AC3_ISO,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=AVC_TS_HD_24_AC3;SONY.COM_PN=AVC_TS_HD_24_AC3,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=AVC_TS_HD_24_AC3_T;SONY.COM_PN=AVC_TS_HD_24_AC3_T,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=MPEG_PS_PAL,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=MPEG_PS_NTSC,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=MPEG_TS_SD_50_L2_T,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=MPEG_TS_SD_60_L2_T,http-get:*:video/mpeg:DLNA.ORG_PN=MPEG_TS_SD_EU_ISO,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=MPEG_TS_SD_EU,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=MPEG_TS_SD_EU_T,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=MPEG_TS_SD_50_AC3_T,http-get:*:video/mpeg:DLNA.ORG_PN=MPEG_TS_HD_50_L2_ISO;SONY.COM_PN=HD2_50_ISO,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=MPEG_TS_SD_60_AC3_T,http-get:*:video/mpeg:DLNA.ORG_PN=MPEG_TS_HD_60_L2_ISO;SONY.COM_PN=HD2_60_ISO,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=MPEG_TS_HD_50_L2_T;SONY.COM_PN=HD2_50_T,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=MPEG_TS_HD_60_L2_T;SONY.COM_PN=HD2_60_T,http-get:*:video/mpeg:DLNA.ORG_PN=AVC_TS_HD_50_AC3_ISO;SONY.COM_PN=AVC_TS_HD_50_AC3_ISO,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=AVC_TS_HD_50_AC3;SONY.COM_PN=AVC_TS_HD_50_AC3,http-get:*:video/mpeg:DLNA.ORG_PN=AVC_TS_HD_60_AC3_ISO;SONY.COM_PN=AVC_TS_HD_60_AC3_ISO,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=AVC_TS_HD_60_AC3;SONY.COM_PN=AVC_TS_HD_60_AC3,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=AVC_TS_HD_50_AC3_T;SONY.COM_PN=AVC_TS_HD_50_AC3_T,http-get:*:video/vnd.dlna.mpeg-tts:DLNA.ORG_PN=AVC_TS_HD_60_AC3_T;SONY.COM_PN=AVC_TS_HD_60_AC3_T,http-get:*:video/x-mp2t-mphl-188:*,http-get:*:video/*:*,http-get:*:audio/*:*,http-get:*:image/*:*,http-get:*:text/srt:*,http-get:*:text/smi:*,http-get:*:text/ssa:*,http-get:*:*:*" 11 | 12 | type connectionManagerService struct { 13 | *Server 14 | upnp.Eventing 15 | } 16 | 17 | func (cms *connectionManagerService) Handle(action string, argsXML []byte, r *http.Request) ([][2]string, error) { 18 | switch action { 19 | case ".GetCurrentConnectionInfo": 20 | return [][2]string{ 21 | {"ConnectionID", "0"}, 22 | {"RcsID", "-1"}, 23 | {"AVTransportID", "-1"}, 24 | {"ProtocolInfo", ""}, 25 | {"PeerConnectionManager", ""}, 26 | {"PeerConnectionID", "-1"}, 27 | {"Direction", "Output"}, 28 | {"Status", "OK"}, 29 | }, nil 30 | case "GetCurrentConnectionIDs": 31 | return [][2]string{ 32 | {"ConnectionIDs", ""}, 33 | }, nil 34 | case "GetProtocolInfo": 35 | return [][2]string{ 36 | {"Source", defaultProtocolInfo}, 37 | {"Sink", ""}, 38 | }, nil 39 | default: 40 | return nil, upnp.InvalidActionError 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /dlna/dms/dms.go: -------------------------------------------------------------------------------- 1 | package dms 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "encoding/xml" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "math/rand" 12 | "net" 13 | "net/http" 14 | "net/http/pprof" 15 | "net/url" 16 | "os" 17 | "os/exec" 18 | "os/user" 19 | "path" 20 | "path/filepath" 21 | "strconv" 22 | "strings" 23 | "sync" 24 | "time" 25 | 26 | "github.com/anacrolix/ffprobe" 27 | "github.com/anacrolix/log" 28 | 29 | "github.com/anacrolix/dms/dlna" 30 | "github.com/anacrolix/dms/soap" 31 | "github.com/anacrolix/dms/ssdp" 32 | "github.com/anacrolix/dms/transcode" 33 | "github.com/anacrolix/dms/upnp" 34 | "github.com/anacrolix/dms/upnpav" 35 | ) 36 | 37 | // This is used when communicating with other devices, such as over HTTP. I don't imagine we're 38 | // popular enough to have special treatment yet. This value would change if we made potentially 39 | // breaking changes to our behaviour that other devices might want to act on. 40 | const serverVersion = "1" 41 | 42 | var ( 43 | serverField = fmt.Sprintf(`Linux/3.4 DLNADOC/1.50 UPnP/1.0 %s/%s`, 44 | userAgentProduct, 45 | serverVersion) 46 | rootDeviceModelName = fmt.Sprintf("%s %s", userAgentProduct, serverVersion) 47 | ) 48 | 49 | const ( 50 | userAgentProduct = "dms" 51 | rootDeviceType = "urn:schemas-upnp-org:device:MediaServer:1" 52 | resPath = "/res" 53 | iconPath = "/icon" 54 | subtitlePath = "/subtitle" 55 | rootDescPath = "/rootDesc.xml" 56 | contentDirectoryEventSubURL = "/evt/ContentDirectory" 57 | serviceControlURL = "/ctl" 58 | deviceIconPath = "/deviceIcon" 59 | ) 60 | 61 | type transcodeSpec struct { 62 | mimeType string 63 | DLNAProfileName string 64 | DLNAFlags string 65 | Transcode func(path string, start, length time.Duration, stderr io.Writer) (r io.ReadCloser, err error) 66 | } 67 | 68 | var transcodes = map[string]transcodeSpec{ 69 | "t": { 70 | mimeType: "video/mpeg", 71 | DLNAProfileName: "MPEG_PS_PAL", 72 | Transcode: transcode.Transcode, 73 | }, 74 | "vp8": {mimeType: "video/webm", Transcode: transcode.VP8Transcode}, 75 | "chromecast": {mimeType: "video/mp4", Transcode: transcode.ChromecastTranscode}, 76 | "web": {mimeType: "video/mp4", Transcode: transcode.WebTranscode}, 77 | } 78 | 79 | func makeDeviceUuid(unique string) string { 80 | h := md5.New() 81 | if _, err := io.WriteString(h, unique); err != nil { 82 | log.Panicf("makeDeviceUuid write failed: %s", err) 83 | } 84 | buf := h.Sum(nil) 85 | return upnp.FormatUUID(buf) 86 | } 87 | 88 | // Groups the service definition with its XML description. 89 | type service struct { 90 | upnp.Service 91 | SCPD string 92 | } 93 | 94 | // Exposed UPnP AV services. 95 | var services = []*service{ 96 | { 97 | Service: upnp.Service{ 98 | ServiceType: "urn:schemas-upnp-org:service:ContentDirectory:1", 99 | ServiceId: "urn:upnp-org:serviceId:ContentDirectory", 100 | EventSubURL: contentDirectoryEventSubURL, 101 | }, 102 | SCPD: contentDirectoryServiceDescription, 103 | }, 104 | { 105 | Service: upnp.Service{ 106 | ServiceType: "urn:schemas-upnp-org:service:ConnectionManager:1", 107 | ServiceId: "urn:upnp-org:serviceId:ConnectionManager", 108 | }, 109 | SCPD: connectionManagerServiceDescription, 110 | }, 111 | { 112 | Service: upnp.Service{ 113 | ServiceType: "urn:microsoft.com:service:X_MS_MediaReceiverRegistrar:1", 114 | ServiceId: "urn:microsoft.com:serviceId:X_MS_MediaReceiverRegistrar", 115 | }, 116 | SCPD: mediaReceiverRegistrarDescription, 117 | }, 118 | } 119 | 120 | // The control URL for every service is the same. We're able to infer the desired service from the request headers. 121 | func init() { 122 | for _, s := range services { 123 | s.ControlURL = serviceControlURL 124 | } 125 | } 126 | 127 | func devices() []string { 128 | return []string{ 129 | "urn:schemas-upnp-org:device:MediaServer:1", 130 | } 131 | } 132 | 133 | func serviceTypes() (ret []string) { 134 | for _, s := range services { 135 | ret = append(ret, s.ServiceType) 136 | } 137 | return 138 | } 139 | 140 | func (me *Server) httpPort() int { 141 | return me.HTTPConn.Addr().(*net.TCPAddr).Port 142 | } 143 | 144 | func (me *Server) serveHTTP() error { 145 | srv := &http.Server{ 146 | Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 147 | if me.LogHeaders { 148 | fmt.Fprintf(os.Stderr, "%s %s\r\n", r.Method, r.RequestURI) 149 | r.Header.Write(os.Stderr) 150 | fmt.Fprintln(os.Stderr) 151 | } 152 | w.Header().Set("Ext", "") 153 | w.Header().Set("Server", serverField) 154 | me.httpServeMux.ServeHTTP(&mitmRespWriter{ 155 | ResponseWriter: w, 156 | logHeader: me.LogHeaders, 157 | }, r) 158 | }), 159 | } 160 | err := srv.Serve(me.HTTPConn) 161 | select { 162 | case <-me.closed: 163 | return nil 164 | default: 165 | return err 166 | } 167 | } 168 | 169 | // An interface with these flags should be valid for SSDP. 170 | const ssdpInterfaceFlags = net.FlagUp | net.FlagMulticast 171 | 172 | func (me *Server) doSSDP() { 173 | var wg sync.WaitGroup 174 | for _, if_ := range me.Interfaces { 175 | if_ := if_ 176 | wg.Add(1) 177 | go func() { 178 | defer wg.Done() 179 | me.ssdpInterface(if_) 180 | }() 181 | } 182 | wg.Wait() 183 | } 184 | 185 | // Run SSDP server on an interface. 186 | func (me *Server) ssdpInterface(if_ net.Interface) { 187 | logger := me.Logger.WithNames("ssdp", if_.Name) 188 | s := ssdp.Server{ 189 | Interface: if_, 190 | Devices: devices(), 191 | Services: serviceTypes(), 192 | Location: func(ip net.IP) string { 193 | return me.location(ip) 194 | }, 195 | Server: serverField, 196 | UUID: me.rootDeviceUUID, 197 | NotifyInterval: me.NotifyInterval, 198 | Logger: logger, 199 | } 200 | if err := s.Init(); err != nil { 201 | if if_.Flags&ssdpInterfaceFlags != ssdpInterfaceFlags { 202 | // Didn't expect it to work anyway. 203 | return 204 | } 205 | if strings.Contains(err.Error(), "listen") { 206 | // OSX has a lot of dud interfaces. Failure to create a socket on 207 | // the interface are what we're expecting if the interface is no 208 | // good. 209 | return 210 | } 211 | logger.Printf("error creating ssdp server on %s: %s", if_.Name, err) 212 | return 213 | } 214 | defer s.Close() 215 | logger.Levelf(log.Info, "started SSDP on %q", if_.Name) 216 | stopped := make(chan struct{}) 217 | go func() { 218 | defer close(stopped) 219 | if err := s.Serve(); err != nil { 220 | logger.Printf("%q: %q\n", if_.Name, err) 221 | } 222 | }() 223 | select { 224 | case <-me.closed: 225 | // Returning will close the server. 226 | case <-stopped: 227 | } 228 | } 229 | 230 | var startTime time.Time 231 | 232 | type Icon struct { 233 | Width, Height, Depth int 234 | Mimetype string 235 | Bytes []byte 236 | } 237 | 238 | type Server struct { 239 | HTTPConn net.Listener 240 | FriendlyName string 241 | Interfaces []net.Interface 242 | httpServeMux *http.ServeMux 243 | RootObjectPath string 244 | OnBrowseDirectChildren func(path string, rootObjectPath string, host, userAgent string) (ret []interface{}, err error) 245 | OnBrowseMetadata func(path string, rootObjectPath string, host, userAgent string) (ret interface{}, err error) 246 | rootDescXML []byte 247 | rootDeviceUUID string 248 | FFProbeCache Cache 249 | closed chan struct{} 250 | ssdpStopped chan struct{} 251 | // The service SOAP handler keyed by service URN. 252 | services map[string]UPnPService 253 | LogHeaders bool 254 | // Disable transcoding, and the resource elements implied in the CDS. 255 | NoTranscode bool 256 | // Force transcoding to certain format of the 'transcodes' map 257 | ForceTranscodeTo string 258 | // Disable media probing with ffprobe 259 | NoProbe bool 260 | Icons []Icon 261 | // Stall event subscription requests until they drop. A workaround for 262 | // some bad clients. 263 | StallEventSubscribe bool 264 | // Time interval between SSPD announces 265 | NotifyInterval time.Duration 266 | // Ignore hidden files and directories 267 | IgnoreHidden bool 268 | // Ignore unreadable files and directories 269 | IgnoreUnreadable bool 270 | // Ignore comma separated list of directories 271 | IgnorePaths []string 272 | // White list of clients 273 | AllowedIpNets []*net.IPNet 274 | // Activate support for dynamic streams configured via .dms.json metadata files 275 | // This feature is not enabled by default, since having write access to a shared media 276 | // folder allows executing arbitrary commands in the context of the DLNA server. 277 | AllowDynamicStreams bool 278 | // pattern where to write transcode logs to. The [tsname] placeholder is replaced with the name 279 | // of the item currently being played. The default is $HOME/.dms/log/[tsname] 280 | TranscodeLogPattern string 281 | Logger log.Logger 282 | eventingLogger log.Logger 283 | } 284 | 285 | // UPnP SOAP service. 286 | type UPnPService interface { 287 | Handle(action string, argsXML []byte, r *http.Request) (respArgs [][2]string, err error) 288 | Subscribe(callback []*url.URL, timeoutSeconds int) (sid string, actualTimeout int, err error) 289 | Unsubscribe(sid string) error 290 | } 291 | 292 | type Cache interface { 293 | Set(key interface{}, value interface{}) 294 | Get(key interface{}) (value interface{}, ok bool) 295 | } 296 | 297 | type dummyFFProbeCache struct{} 298 | 299 | func (dummyFFProbeCache) Set(interface{}, interface{}) {} 300 | 301 | func (dummyFFProbeCache) Get(interface{}) (interface{}, bool) { 302 | return nil, false 303 | } 304 | 305 | // Public definition so that external modules can persist cache contents. 306 | type FfprobeCacheItem struct { 307 | Key ffmpegInfoCacheKey 308 | Value *ffprobe.Info 309 | } 310 | 311 | // update the UPnP object fields from ffprobe data 312 | // priority is given the format section, and then the streams sequentially 313 | func itemExtra(item *upnpav.Object, info *ffprobe.Info) { 314 | setFromTags := func(m map[string]interface{}) { 315 | for key, val := range m { 316 | setIfUnset := func(s *string) { 317 | if *s == "" { 318 | *s = val.(string) 319 | } 320 | } 321 | switch strings.ToLower(key) { 322 | case "tag:artist": 323 | setIfUnset(&item.Artist) 324 | case "tag:album": 325 | setIfUnset(&item.Album) 326 | case "tag:genre": 327 | setIfUnset(&item.Genre) 328 | } 329 | } 330 | } 331 | setFromTags(info.Format) 332 | for _, m := range info.Streams { 333 | setFromTags(m) 334 | } 335 | } 336 | 337 | type ffmpegInfoCacheKey struct { 338 | Path string 339 | ModTime int64 340 | } 341 | 342 | func transcodeResources(host, path, resolution, duration string) (ret []upnpav.Resource) { 343 | ret = make([]upnpav.Resource, 0, len(transcodes)) 344 | for k, v := range transcodes { 345 | ret = append(ret, upnpav.Resource{ 346 | ProtocolInfo: fmt.Sprintf("http-get:*:%s:%s", v.mimeType, dlna.ContentFeatures{ 347 | SupportTimeSeek: true, 348 | Transcoded: true, 349 | ProfileName: v.DLNAProfileName, 350 | }.String()), 351 | URL: (&url.URL{ 352 | Scheme: "http", 353 | Host: host, 354 | Path: resPath, 355 | RawQuery: url.Values{ 356 | "path": {path}, 357 | "transcode": {k}, 358 | }.Encode(), 359 | }).String(), 360 | Resolution: resolution, 361 | Duration: duration, 362 | }) 363 | } 364 | return 365 | } 366 | 367 | func parseDLNARangeHeader(val string) (ret dlna.NPTRange, err error) { 368 | if !strings.HasPrefix(val, "npt=") { 369 | err = errors.New("bad prefix") 370 | return 371 | } 372 | ret, err = dlna.ParseNPTRange(val[len("npt="):]) 373 | if err != nil { 374 | return 375 | } 376 | return 377 | } 378 | 379 | // Determines the time-based range to transcode, and sets the appropriate 380 | // headers. Returns !ok if there was an error and the caller should stop 381 | // handling the request. 382 | func handleDLNARange(w http.ResponseWriter, hs http.Header, dynamicMode bool) (r dlna.NPTRange, partialResponse, ok bool) { 383 | if dynamicMode || len(hs[http.CanonicalHeaderKey(dlna.TimeSeekRangeDomain)]) == 0 { 384 | ok = true 385 | return 386 | } 387 | partialResponse = true 388 | h := hs.Get(dlna.TimeSeekRangeDomain) 389 | r, err := parseDLNARangeHeader(h) 390 | if err != nil { 391 | http.Error(w, err.Error(), http.StatusBadRequest) 392 | return 393 | } 394 | // Passing an exact NPT duration seems to cause trouble pass the "iono" 395 | // (*) duration instead. 396 | // 397 | // TODO: Check that the request range can't already have /. 398 | w.Header().Set(dlna.TimeSeekRangeDomain, h+"/*") 399 | ok = true 400 | return 401 | } 402 | 403 | func writeResponseCode(w http.ResponseWriter, partialResponse bool) { 404 | w.WriteHeader(func() int { 405 | if partialResponse { 406 | return http.StatusPartialContent 407 | } else { 408 | return http.StatusOK 409 | } 410 | }()) 411 | } 412 | 413 | func (me *Server) serveDLNATranscode(w http.ResponseWriter, r *http.Request, path_ string, ts transcodeSpec, tsname string, dynamicMode bool) { 414 | w.Header().Set(dlna.TransferModeDomain, "Streaming") 415 | w.Header().Set("content-type", ts.mimeType) 416 | w.Header().Set(dlna.ContentFeaturesDomain, (dlna.ContentFeatures{ 417 | Transcoded: true, 418 | SupportTimeSeek: !dynamicMode, 419 | ProfileName: ts.DLNAProfileName, 420 | Flags: ts.DLNAFlags, 421 | }).String()) 422 | // If a range of any kind is given, we have to respond with 206 if we're 423 | // interpreting that range. Since only the DLNA range is handled in this 424 | // function, it alone determines if we'll give a partial response. 425 | range_, partialResponse, ok := handleDLNARange(w, r.Header, dynamicMode) 426 | if !ok { 427 | return 428 | } 429 | 430 | // Samsung Frame TVs send a HEAD request first. If we don't terminate processing here, 431 | // the TV will keep reading the data and crash eventually :) 432 | if r.Method == "HEAD" { 433 | writeResponseCode(w, partialResponse) 434 | return 435 | } 436 | 437 | var logTsName string 438 | if !dynamicMode { 439 | ffInfo, _ := me.ffmpegProbe(path_) 440 | if ffInfo != nil { 441 | if duration, err := ffInfo.Duration(); err == nil { 442 | s := fmt.Sprintf("%f", duration.Seconds()) 443 | w.Header().Set("content-duration", s) 444 | w.Header().Set("x-content-duration", s) 445 | } 446 | } 447 | 448 | logTsName = filepath.Join(tsname, filepath.Base(path_)) 449 | } else { 450 | logTsName = tsname 451 | } 452 | stderrPath := strings.Replace(me.TranscodeLogPattern, "[tsname]", logTsName, -1) 453 | var logFile io.Writer 454 | if stderrPath != "" { 455 | os.MkdirAll(filepath.Dir(stderrPath), 0o750) 456 | aLogFile, err := os.Create(stderrPath) 457 | if err != nil { 458 | log.Printf("couldn't create transcode log file: %s", err) 459 | } else { 460 | defer aLogFile.Close() 461 | log.Printf("logging transcode to %q", stderrPath) 462 | } 463 | logFile = aLogFile 464 | } 465 | p, err := ts.Transcode(path_, range_.Start, range_.End-range_.Start, logFile) 466 | if err != nil { 467 | http.Error(w, err.Error(), http.StatusInternalServerError) 468 | return 469 | } 470 | defer p.Close() 471 | // I recently switched this to returning 200 if no range is specified for 472 | // pure UPnP clients. It's possible that DLNA clients will *always* expect 473 | // 206. It appears the HTTP standard requires that 206 only be used if a 474 | // response is not interpreting any range headers. 475 | writeResponseCode(w, partialResponse) 476 | io.Copy(w, p) 477 | } 478 | 479 | func init() { 480 | startTime = time.Now() 481 | } 482 | 483 | func getDefaultFriendlyName() string { 484 | return fmt.Sprintf("%s: %s on %s", 485 | rootDeviceModelName, 486 | func() string { 487 | user, err := user.Current() 488 | if err != nil { 489 | log.Panicf("getDefaultFriendlyName could not get username: %s", err) 490 | } 491 | return user.Name 492 | }(), 493 | func() string { 494 | name, err := os.Hostname() 495 | if err != nil { 496 | log.Panicf("getDefaultFriendlyName could not get hostname: %s", err) 497 | } 498 | return name 499 | }()) 500 | } 501 | 502 | func xmlMarshalOrPanic(value interface{}) []byte { 503 | ret, err := xml.MarshalIndent(value, "", " ") 504 | if err != nil { 505 | log.Panicf("xmlMarshalOrPanic failed to marshal %v: %s", value, err) 506 | } 507 | return ret 508 | } 509 | 510 | // TODO: Document the use of this for debugging. 511 | type mitmRespWriter struct { 512 | http.ResponseWriter 513 | loggedHeader bool 514 | logHeader bool 515 | } 516 | 517 | func (me *mitmRespWriter) WriteHeader(code int) { 518 | me.doLogHeader(code) 519 | me.ResponseWriter.WriteHeader(code) 520 | } 521 | 522 | func (me *mitmRespWriter) doLogHeader(code int) { 523 | if !me.logHeader { 524 | return 525 | } 526 | fmt.Fprintln(os.Stderr, code) 527 | for k, v := range me.Header() { 528 | fmt.Fprintln(os.Stderr, k, v) 529 | } 530 | fmt.Fprintln(os.Stderr) 531 | me.loggedHeader = true 532 | } 533 | 534 | func (me *mitmRespWriter) Write(b []byte) (int, error) { 535 | if !me.loggedHeader { 536 | me.doLogHeader(200) 537 | } 538 | return me.ResponseWriter.Write(b) 539 | } 540 | 541 | func (me *mitmRespWriter) CloseNotify() <-chan bool { 542 | return me.ResponseWriter.(http.CloseNotifier).CloseNotify() 543 | } 544 | 545 | // Set the SCPD serve paths. 546 | func init() { 547 | for _, s := range services { 548 | lastInd := strings.LastIndex(s.ServiceId, ":") 549 | p := path.Join("/scpd", s.ServiceId[lastInd+1:]) 550 | s.SCPDURL = p + ".xml" 551 | } 552 | } 553 | 554 | // Install handlers to serve SCPD for each UPnP service. 555 | func handleSCPDs(mux *http.ServeMux) { 556 | for _, s := range services { 557 | mux.HandleFunc(s.SCPDURL, func(serviceDesc string) http.HandlerFunc { 558 | return func(w http.ResponseWriter, r *http.Request) { 559 | w.Header().Set("content-type", `text/xml; charset="utf-8"`) 560 | http.ServeContent(w, r, "", startTime, bytes.NewReader([]byte(serviceDesc))) 561 | } 562 | }(s.SCPD)) 563 | } 564 | } 565 | 566 | // Marshal SOAP response arguments into a response XML snippet. 567 | func marshalSOAPResponse(sa upnp.SoapAction, args [][2]string) []byte { 568 | soapArgs := make([]soap.Arg, 0, len(args)) 569 | for _, arg := range args { 570 | argName, value := arg[0], arg[1] 571 | soapArgs = append(soapArgs, soap.Arg{ 572 | XMLName: xml.Name{Local: argName}, 573 | Value: value, 574 | }) 575 | } 576 | return []byte(fmt.Sprintf(`%[3]s`, sa.Action, sa.ServiceURN.String(), xmlMarshalOrPanic(soapArgs))) 577 | } 578 | 579 | // Handle a SOAP request and return the response arguments or UPnP error. 580 | func (me *Server) soapActionResponse(sa upnp.SoapAction, actionRequestXML []byte, r *http.Request) ([][2]string, error) { 581 | service, ok := me.services[sa.Type] 582 | if !ok { 583 | // TODO: What's the invalid service error?! 584 | return nil, upnp.Errorf(upnp.InvalidActionErrorCode, "Invalid service: %s", sa.Type) 585 | } 586 | return service.Handle(sa.Action, actionRequestXML, r) 587 | } 588 | 589 | // Handle a service control HTTP request. 590 | func (me *Server) serviceControlHandler(w http.ResponseWriter, r *http.Request) { 591 | found := false 592 | clientIp, _, _ := net.SplitHostPort(r.RemoteAddr) 593 | if zoneDelimiterIdx := strings.Index(clientIp, "%"); zoneDelimiterIdx != -1 { 594 | // IPv6 addresses may have the form address%zone (e.g. ::1%eth0) 595 | clientIp = clientIp[:zoneDelimiterIdx] 596 | } 597 | for _, ipnet := range me.AllowedIpNets { 598 | if ipnet.Contains(net.ParseIP(clientIp)) { 599 | found = true 600 | } 601 | } 602 | if !found { 603 | log.Printf("not allowed client %s, %+v", clientIp, me.AllowedIpNets) 604 | http.Error(w, "forbidden", http.StatusForbidden) 605 | return 606 | } 607 | soapActionString := r.Header.Get("SOAPACTION") 608 | soapAction, err := upnp.ParseActionHTTPHeader(soapActionString) 609 | if err != nil { 610 | http.Error(w, err.Error(), http.StatusBadRequest) 611 | return 612 | } 613 | var env soap.Envelope 614 | if err := xml.NewDecoder(r.Body).Decode(&env); err != nil { 615 | http.Error(w, err.Error(), http.StatusBadRequest) 616 | return 617 | } 618 | // AwoX/1.1 UPnP/1.0 DLNADOC/1.50 619 | // log.Println(r.UserAgent()) 620 | w.Header().Set("Content-Type", `text/xml; charset="utf-8"`) 621 | w.Header().Set("Ext", "") 622 | w.Header().Set("Server", serverField) 623 | soapRespXML, code := func() ([]byte, int) { 624 | respArgs, err := me.soapActionResponse(soapAction, env.Body.Action, r) 625 | if err != nil { 626 | upnpErr := upnp.ConvertError(err) 627 | return xmlMarshalOrPanic(soap.NewFault("UPnPError", upnpErr)), 500 628 | } 629 | return marshalSOAPResponse(soapAction, respArgs), 200 630 | }() 631 | bodyStr := fmt.Sprintf(`%s`, soapRespXML) 632 | // Compatibility with Samsung Frame TV's - they don't display an empty content directory without this hack: 633 | bodyStr = strings.Replace(bodyStr, """, `"`, -1) 634 | w.WriteHeader(code) 635 | if _, err := w.Write([]byte(bodyStr)); err != nil { 636 | log.Print(err) 637 | } 638 | } 639 | 640 | func safeFilePath(root, given string) string { 641 | return filepath.Join(root, filepath.FromSlash(path.Clean("/" + given))[1:]) 642 | } 643 | 644 | func (s *Server) filePath(_path string) string { 645 | return safeFilePath(s.RootObjectPath, _path) 646 | } 647 | 648 | func (me *Server) serveIcon(w http.ResponseWriter, r *http.Request) { 649 | filePath := me.filePath(r.URL.Query().Get("path")) 650 | c := r.URL.Query().Get("c") 651 | if c == "" { 652 | c = "png" 653 | } 654 | args := []string{} 655 | _, fqThumbnail := os.LookupEnv("DMS_THUMBNAIL_FULLQUALITY") 656 | if fqThumbnail { 657 | args = append(args, "-s", "0", "-q", "10") 658 | } 659 | 660 | _, randThumbnail := os.LookupEnv("DMS_THUMBNAIL_RANDOM") 661 | if randThumbnail { 662 | args = append(args, "-t", strconv.Itoa(rand.Intn(100))) 663 | } 664 | 665 | args = append(args, "-i", filePath, "-o", "/dev/stdout", "-c"+c) 666 | cmd := exec.Command("ffmpegthumbnailer", args...) 667 | // cmd.Stderr = os.Stderr 668 | body, err := cmd.Output() 669 | if err != nil { 670 | // serve 1st Icon if no ffmpegthumbnailer 671 | w.Header().Set("Content-Type", me.Icons[0].Mimetype) 672 | http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(me.Icons[0].Bytes)) 673 | // http.Error(w, err.Error(), http.StatusInternalServerError) 674 | return 675 | } 676 | http.ServeContent(w, r, "", time.Now(), bytes.NewReader(body)) 677 | } 678 | 679 | func (me *Server) serveSubtitle(w http.ResponseWriter, r *http.Request) { 680 | filePath := me.filePath(r.URL.Query().Get("path")) 681 | subtitleFilePath := strings.TrimSuffix(filePath, filepath.Ext(filePath)) + ".srt" 682 | http.ServeFile(w, r, subtitleFilePath) 683 | } 684 | 685 | func (server *Server) contentDirectoryInitialEvent(urls []*url.URL, sid string) { 686 | body := xmlMarshalOrPanic(upnp.PropertySet{ 687 | Properties: []upnp.Property{ 688 | { 689 | Variable: upnp.Variable{ 690 | XMLName: xml.Name{ 691 | Local: "SystemUpdateID", 692 | }, 693 | Value: "0", 694 | }, 695 | }, 696 | // upnp.Property{ 697 | // Variable: upnp.Variable{ 698 | // XMLName: xml.Name{ 699 | // Local: "ContainerUpdateIDs", 700 | // }, 701 | // }, 702 | // }, 703 | // upnp.Property{ 704 | // Variable: upnp.Variable{ 705 | // XMLName: xml.Name{ 706 | // Local: "TransferIDs", 707 | // }, 708 | // }, 709 | // }, 710 | }, 711 | Space: "urn:schemas-upnp-org:event-1-0", 712 | }) 713 | body = append([]byte(``+"\n"), body...) 714 | server.eventingLogger.Print(string(body)) 715 | for _, _url := range urls { 716 | bodyReader := bytes.NewReader(body) 717 | req, err := http.NewRequest("NOTIFY", _url.String(), bodyReader) 718 | if err != nil { 719 | log.Printf("Could not create a request to notify %s: %s", _url.String(), err) 720 | continue 721 | } 722 | req.Header["CONTENT-TYPE"] = []string{`text/xml; charset="utf-8"`} 723 | req.Header["NT"] = []string{"upnp:event"} 724 | req.Header["NTS"] = []string{"upnp:propchange"} 725 | req.Header["SID"] = []string{sid} 726 | req.Header["SEQ"] = []string{"0"} 727 | // req.Header["TRANSFER-ENCODING"] = []string{"chunked"} 728 | // req.ContentLength = int64(bodyReader.Len()) 729 | server.eventingLogger.Print(req.Header) 730 | server.eventingLogger.Print("starting notify") 731 | resp, err := http.DefaultClient.Do(req) 732 | server.eventingLogger.Print("finished notify") 733 | if err != nil { 734 | log.Printf("Could not notify %s: %s", _url.String(), err) 735 | continue 736 | } 737 | server.eventingLogger.Print(resp) 738 | b, _ := ioutil.ReadAll(resp.Body) 739 | server.eventingLogger.Println(string(b)) 740 | resp.Body.Close() 741 | } 742 | } 743 | 744 | func (server *Server) contentDirectoryEventSubHandler(w http.ResponseWriter, r *http.Request) { 745 | if server.StallEventSubscribe { 746 | // I have an LG TV that doesn't like my eventing implementation. 747 | // Returning unimplemented (501?) errors, results in repeat subscribe 748 | // attempts which hits some kind of error count limit on the TV 749 | // causing it to forcefully disconnect. It also won't work if the CDS 750 | // service doesn't include an EventSubURL. The best thing I can do is 751 | // cause every attempt to subscribe to timeout on the TV end, which 752 | // reduces the error rate enough that the TV continues to operate 753 | // without eventing. 754 | // 755 | // I've not found a reliable way to identify this TV, since it and 756 | // others don't seem to include any client-identifying headers on 757 | // SUBSCRIBE requests. 758 | // 759 | // TODO: Get eventing to work with the problematic TV. 760 | t := time.Now() 761 | <-w.(http.CloseNotifier).CloseNotify() 762 | server.eventingLogger.Printf("stalled subscribe connection went away after %s", time.Since(t)) 763 | return 764 | } 765 | // The following code is a work in progress. It partially implements 766 | // the spec on eventing but hasn't been completed as I have nothing to 767 | // test it with. 768 | server.eventingLogger.Print(r.Header) 769 | service := server.services["ContentDirectory"] 770 | server.eventingLogger.Println(r.RemoteAddr, r.Method, r.Header.Get("SID")) 771 | if r.Method == "SUBSCRIBE" && r.Header.Get("SID") == "" { 772 | urls := upnp.ParseCallbackURLs(r.Header.Get("CALLBACK")) 773 | server.eventingLogger.Println(urls) 774 | var timeout int 775 | fmt.Sscanf(r.Header.Get("TIMEOUT"), "Second-%d", &timeout) 776 | server.eventingLogger.Println(timeout, r.Header.Get("TIMEOUT")) 777 | sid, timeout, _ := service.Subscribe(urls, timeout) 778 | w.Header()["SID"] = []string{sid} 779 | w.Header()["TIMEOUT"] = []string{fmt.Sprintf("Second-%d", timeout)} 780 | // TODO: Shouldn't have to do this to get headers logged. 781 | w.WriteHeader(http.StatusOK) 782 | go func() { 783 | time.Sleep(100 * time.Millisecond) 784 | server.contentDirectoryInitialEvent(urls, sid) 785 | }() 786 | } else if r.Method == "SUBSCRIBE" { 787 | http.Error(w, "meh", http.StatusPreconditionFailed) 788 | } else { 789 | server.eventingLogger.Printf("unhandled event method: %s", r.Method) 790 | } 791 | } 792 | 793 | func (server *Server) serveDynamicStream(w http.ResponseWriter, r *http.Request, metadataPath string) error { 794 | dmsMediaItem, err := readDynamicStream(metadataPath) 795 | if err != nil { 796 | return err 797 | } 798 | 799 | aindex := 0 800 | index := r.URL.Query().Get("index") 801 | if index != "" { 802 | aindex, err = strconv.Atoi(index) 803 | if err != nil { 804 | return err 805 | } 806 | } 807 | 808 | if aindex < 0 || aindex >= len(dmsMediaItem.Resources) { 809 | return fmt.Errorf("invalid index %d, corresponding stream not found", aindex) 810 | } 811 | dmsStream := dmsMediaItem.Resources[aindex] 812 | dmsTsSpec := transcodeSpec{ 813 | DLNAProfileName: dmsStream.DlnaProfileName, 814 | DLNAFlags: dmsStream.DlnaFlags, 815 | mimeType: dmsStream.MimeType, 816 | Transcode: transcode.Exec, 817 | } 818 | server.serveDLNATranscode(w, r, dmsStream.Command, dmsTsSpec, filepath.Base(metadataPath), true) 819 | return nil 820 | } 821 | 822 | func (server *Server) initMux(mux *http.ServeMux) { 823 | // Handle root (presentationURL) 824 | mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { 825 | resp.Header().Set("content-type", "text/html") 826 | err := rootTmpl.Execute(resp, struct { 827 | Readonly bool 828 | Path string 829 | }{ 830 | true, 831 | server.RootObjectPath, 832 | }) 833 | if err != nil { 834 | log.Println(err) 835 | } 836 | }) 837 | mux.HandleFunc(contentDirectoryEventSubURL, server.contentDirectoryEventSubHandler) 838 | mux.HandleFunc(iconPath, server.serveIcon) 839 | mux.HandleFunc(subtitlePath, server.serveSubtitle) 840 | mux.HandleFunc(resPath, func(w http.ResponseWriter, r *http.Request) { 841 | filePath := server.filePath(r.URL.Query().Get("path")) 842 | if ignored, err := server.IgnorePath(filePath); err != nil { 843 | http.Error(w, err.Error(), http.StatusInternalServerError) 844 | return 845 | } else if ignored { 846 | http.Error(w, "no such object", http.StatusNotFound) 847 | return 848 | } 849 | if strings.HasSuffix(filePath, dmsMetadataSuffix) { 850 | if server.AllowDynamicStreams { 851 | err := server.serveDynamicStream(w, r, filePath) 852 | if err != nil { 853 | http.Error(w, err.Error(), http.StatusInternalServerError) 854 | } 855 | return 856 | } else { 857 | http.Error(w, "dynamic streams are disabled", http.StatusNotFound) 858 | return 859 | } 860 | } 861 | var k string 862 | if server.ForceTranscodeTo != "" { 863 | k = server.ForceTranscodeTo 864 | } else { 865 | k = r.URL.Query().Get("transcode") 866 | } 867 | mimeType, err := MimeTypeByPath(filePath) 868 | if k == "" || mimeType.IsImage() { 869 | if err != nil { 870 | http.Error(w, err.Error(), http.StatusInternalServerError) 871 | return 872 | } 873 | w.Header().Set("Content-Type", string(mimeType)) 874 | w.Header().Set("Content-Disposition", "attachment; filename="+strconv.Quote(path.Base(filePath))) 875 | http.ServeFile(w, r, filePath) 876 | return 877 | } 878 | if server.NoTranscode { 879 | http.Error(w, "transcodes disabled", http.StatusNotFound) 880 | return 881 | } 882 | spec, ok := transcodes[k] 883 | if !ok { 884 | http.Error(w, fmt.Sprintf("bad transcode spec key: %s", k), http.StatusBadRequest) 885 | return 886 | } 887 | server.serveDLNATranscode(w, r, filePath, spec, k, false) 888 | }) 889 | mux.HandleFunc(rootDescPath, func(w http.ResponseWriter, r *http.Request) { 890 | w.Header().Set("content-type", `text/xml; charset="utf-8"`) 891 | w.Header().Set("content-length", fmt.Sprint(len(server.rootDescXML))) 892 | w.Header().Set("server", serverField) 893 | w.Write(server.rootDescXML) 894 | }) 895 | handleSCPDs(mux) 896 | mux.HandleFunc(serviceControlURL, server.serviceControlHandler) 897 | mux.HandleFunc("/debug/pprof/", pprof.Index) 898 | // DeviceIcons 899 | iconHandl := func(w http.ResponseWriter, r *http.Request) { 900 | idStr := path.Base(r.URL.Path) 901 | id, _ := strconv.Atoi(idStr) 902 | if id < 0 || id >= len(server.Icons) { 903 | id = 0 904 | } 905 | di := server.Icons[id] 906 | w.Header().Set("Content-Type", di.Mimetype) 907 | http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(di.Bytes)) 908 | } 909 | for i := range server.Icons { 910 | mux.HandleFunc(fmt.Sprintf("%s/%d", deviceIconPath, i), iconHandl) 911 | } 912 | } 913 | 914 | func (s *Server) initServices() (err error) { 915 | urn, err := upnp.ParseServiceType(services[0].ServiceType) 916 | if err != nil { 917 | return 918 | } 919 | urn1, err := upnp.ParseServiceType(services[1].ServiceType) 920 | if err != nil { 921 | return 922 | } 923 | urn2, err := upnp.ParseServiceType(services[2].ServiceType) 924 | if err != nil { 925 | return 926 | } 927 | s.services = map[string]UPnPService{ 928 | urn.Type: &contentDirectoryService{ 929 | Server: s, 930 | }, 931 | urn1.Type: &connectionManagerService{ 932 | Server: s, 933 | }, 934 | urn2.Type: &mediaReceiverRegistrarService{ 935 | Server: s, 936 | }, 937 | } 938 | return 939 | } 940 | 941 | func (srv *Server) Init() (err error) { 942 | srv.eventingLogger = srv.Logger.WithNames("eventing") 943 | srv.eventingLogger.Levelf(log.Debug, "hello %v", "world") 944 | if err = srv.initServices(); err != nil { 945 | return 946 | } 947 | srv.closed = make(chan struct{}) 948 | if srv.FriendlyName == "" { 949 | srv.FriendlyName = getDefaultFriendlyName() 950 | } 951 | if srv.HTTPConn == nil { 952 | srv.HTTPConn, err = net.Listen("tcp", "") 953 | if err != nil { 954 | return 955 | } 956 | } 957 | if srv.Interfaces == nil { 958 | ifs, err := net.Interfaces() 959 | if err != nil { 960 | log.Print(err) 961 | } 962 | var tmp []net.Interface 963 | for _, if_ := range ifs { 964 | if if_.Flags&net.FlagUp == 0 || if_.MTU <= 0 { 965 | continue 966 | } 967 | tmp = append(tmp, if_) 968 | } 969 | srv.Interfaces = tmp 970 | } 971 | if srv.FFProbeCache == nil { 972 | srv.FFProbeCache = dummyFFProbeCache{} 973 | } 974 | srv.httpServeMux = http.NewServeMux() 975 | srv.rootDeviceUUID = makeDeviceUuid(srv.FriendlyName) 976 | srv.rootDescXML, err = xml.MarshalIndent( 977 | upnp.DeviceDesc{ 978 | NSDLNA: "urn:schemas-dlna-org:device-1-0", 979 | NSSEC: "http://www.sec.co.kr/dlna", 980 | SpecVersion: upnp.SpecVersion{Major: 1, Minor: 0}, 981 | Device: upnp.Device{ 982 | DeviceType: rootDeviceType, 983 | FriendlyName: srv.FriendlyName, 984 | Manufacturer: "Matt Joiner ", 985 | ModelName: rootDeviceModelName, 986 | UDN: srv.rootDeviceUUID, 987 | VendorXML: ` 988 | 989 | DMS-1.50 990 | M-DMS-1.50 991 | smi,DCM10,getMediaInfo.sec,getCaptionInfo.sec 992 | smi,DCM10,getMediaInfo.sec,getCaptionInfo.sec`, 993 | ServiceList: func() (ss []upnp.Service) { 994 | for _, s := range services { 995 | ss = append(ss, s.Service) 996 | } 997 | return 998 | }(), 999 | IconList: func() (ret []upnp.Icon) { 1000 | for i, di := range srv.Icons { 1001 | ret = append(ret, upnp.Icon{ 1002 | Height: di.Height, 1003 | Width: di.Width, 1004 | Depth: di.Depth, 1005 | Mimetype: di.Mimetype, 1006 | URL: fmt.Sprintf("%s/%d", deviceIconPath, i), 1007 | }) 1008 | } 1009 | return 1010 | }(), 1011 | PresentationURL: "/", 1012 | }, 1013 | }, 1014 | " ", " ") 1015 | if err != nil { 1016 | return 1017 | } 1018 | srv.rootDescXML = append([]byte(``), srv.rootDescXML...) 1019 | srv.Logger.Println("HTTP srv on", srv.HTTPConn.Addr()) 1020 | srv.initMux(srv.httpServeMux) 1021 | srv.ssdpStopped = make(chan struct{}) 1022 | return nil 1023 | } 1024 | 1025 | // Deprecated: Use Init and then Run. There's a race calling Close on a Server that's had Serve 1026 | // called on it. 1027 | func (srv *Server) Serve() (err error) { 1028 | err = srv.Init() 1029 | if err != nil { 1030 | return 1031 | } 1032 | return srv.Run() 1033 | } 1034 | 1035 | func (srv *Server) Run() (err error) { 1036 | go func() { 1037 | srv.doSSDP() 1038 | close(srv.ssdpStopped) 1039 | }() 1040 | return srv.serveHTTP() 1041 | } 1042 | 1043 | func (srv *Server) Close() (err error) { 1044 | close(srv.closed) 1045 | err = srv.HTTPConn.Close() 1046 | <-srv.ssdpStopped 1047 | return 1048 | } 1049 | 1050 | func didl_lite(chardata string) string { 1051 | return `` + 1056 | chardata + 1057 | `` 1058 | } 1059 | 1060 | func (me *Server) location(ip net.IP) string { 1061 | url := url.URL{ 1062 | Scheme: "http", 1063 | Host: (&net.TCPAddr{ 1064 | IP: ip, 1065 | Port: me.httpPort(), 1066 | }).String(), 1067 | Path: rootDescPath, 1068 | } 1069 | return url.String() 1070 | } 1071 | 1072 | // Can return nil info with nil err if an earlier Probe gave an error. 1073 | func (srv *Server) ffmpegProbe(path string) (info *ffprobe.Info, err error) { 1074 | // We don't want relative paths in the cache. 1075 | path, err = filepath.Abs(path) 1076 | if err != nil { 1077 | return 1078 | } 1079 | fi, err := os.Stat(path) 1080 | if err != nil { 1081 | return 1082 | } 1083 | key := ffmpegInfoCacheKey{path, fi.ModTime().UnixNano()} 1084 | value, ok := srv.FFProbeCache.Get(key) 1085 | if !ok { 1086 | info, err = ffprobe.Run(path) 1087 | err = suppressFFmpegProbeDataErrors(err) 1088 | srv.FFProbeCache.Set(key, info) 1089 | return 1090 | } 1091 | info = value.(*ffprobe.Info) 1092 | return 1093 | } 1094 | 1095 | // IgnorePath detects if a file/directory should be ignored. 1096 | func (server *Server) IgnorePath(path string) (bool, error) { 1097 | if !filepath.IsAbs(path) { 1098 | return false, fmt.Errorf("Path must be absolute: %s", path) 1099 | } 1100 | if server.IgnoreHidden { 1101 | if hidden, err := isHiddenPath(path); err != nil { 1102 | return false, err 1103 | } else if hidden { 1104 | log.Print(path, " ignored: hidden") 1105 | return true, nil 1106 | } 1107 | } 1108 | if server.IgnoreUnreadable { 1109 | if readable, err := isReadablePath(path); err != nil { 1110 | return false, err 1111 | } else if !readable { 1112 | log.Print(path, " ignored: unreadable") 1113 | return true, nil 1114 | } 1115 | } 1116 | 1117 | for _, element := range server.IgnorePaths { 1118 | if strings.Contains(path, fmt.Sprintf("/%s/", element)) { 1119 | log.Print(path, " ignored: in ignore list") 1120 | return true, nil 1121 | } 1122 | } 1123 | 1124 | return false, nil 1125 | } 1126 | 1127 | func tryToOpenPath(path string) (bool, error) { 1128 | // Ugly but portable way to check if we can open a file/directory 1129 | if fh, err := os.Open(path); err == nil { 1130 | fh.Close() 1131 | return true, nil 1132 | } else if !os.IsPermission(err) { 1133 | return false, err 1134 | } 1135 | return false, nil 1136 | } 1137 | -------------------------------------------------------------------------------- /dlna/dms/dms_others.go: -------------------------------------------------------------------------------- 1 | //go:build !linux && !darwin && !windows 2 | // +build !linux,!darwin,!windows 3 | 4 | package dms 5 | 6 | func isHiddenPath(path string) (bool, error) { 7 | return false, nil 8 | } 9 | 10 | func isReadablePath(path string) (bool, error) { 11 | return tryToOpenPath(path) 12 | } 13 | -------------------------------------------------------------------------------- /dlna/dms/dms_test.go: -------------------------------------------------------------------------------- 1 | package dms 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "runtime" 7 | "testing" 8 | ) 9 | 10 | type safeFilePathTestCase struct { 11 | root, given, expected string 12 | } 13 | 14 | func TestSafeFilePath(t *testing.T) { 15 | var cases []safeFilePathTestCase 16 | if runtime.GOOS == "windows" { 17 | cases = []safeFilePathTestCase{ 18 | {"c:", "/", "c:."}, 19 | {"c:", "/test", "c:test"}, 20 | {"c:\\", "/", "c:\\"}, 21 | {"c:\\", "/test", "c:\\test"}, 22 | {"c:\\hello", "../windows", "c:\\hello\\windows"}, 23 | {"c:\\hello", "/../windows", "c:\\hello\\windows"}, 24 | {"c:\\hello", "/", "c:\\hello"}, 25 | {"c:\\hello", "./world", "c:\\hello\\world"}, 26 | {"c:\\hello", "/", "c:\\hello"}, 27 | // These two ones are invalid but, as this actually prevents to serve them, it is fine 28 | {"c:\\foo", "c:/windows/", "c:\\foo\\c:\\windows"}, 29 | {"c:\\foo", "e:/", "c:\\foo\\e:"}, 30 | } 31 | } else { 32 | cases = []safeFilePathTestCase{ 33 | {"/", "..", "/"}, 34 | {"/hello", "..//", "/hello"}, 35 | {"", "/precious", "precious"}, 36 | {".", "///precious", "precious"}, 37 | } 38 | } 39 | t.Logf("running %d test cases", len(cases)) 40 | for _, _case := range cases { 41 | a := safeFilePath(_case.root, _case.given) 42 | if a != _case.expected { 43 | t.Errorf("expected %q from %q and %q but got %q", _case.expected, _case.root, _case.given, a) 44 | } 45 | } 46 | } 47 | 48 | func TestRequest(t *testing.T) { 49 | resp, err := http.NewRequest("NOTIFY", "/", nil) 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | buf := bytes.NewBuffer(nil) 54 | resp.Write(buf) 55 | t.Logf("%q", buf.String()) 56 | } 57 | 58 | func TestResponse(t *testing.T) { 59 | var resp http.Response 60 | resp.StatusCode = http.StatusOK 61 | resp.Header = make(http.Header) 62 | resp.Header["SID"] = []string{"uuid:1337"} 63 | var buf bytes.Buffer 64 | resp.Write(&buf) 65 | t.Logf("%q", buf.String()) 66 | } 67 | -------------------------------------------------------------------------------- /dlna/dms/dms_unix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | // +build linux darwin 3 | 4 | package dms 5 | 6 | import ( 7 | "strings" 8 | 9 | "golang.org/x/sys/unix" 10 | ) 11 | 12 | func isHiddenPath(path string) (bool, error) { 13 | return strings.Contains(path, "/."), nil 14 | } 15 | 16 | func isReadablePath(path string) (bool, error) { 17 | err := unix.Access(path, unix.R_OK) 18 | switch err { 19 | case nil: 20 | return true, nil 21 | case unix.EACCES: 22 | return false, nil 23 | default: 24 | return false, err 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /dlna/dms/dms_unix_test.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | // +build linux darwin 3 | 4 | package dms 5 | 6 | import "testing" 7 | 8 | func TestIsHiddenPath(t *testing.T) { 9 | data := map[string]bool{ 10 | "/some/path": false, 11 | "/some/foo.bar": false, 12 | "/some/path/.hidden": true, 13 | "/some/.hidden/path": true, 14 | "/.hidden/path": true, 15 | } 16 | for path, expected := range data { 17 | if actual, err := isHiddenPath(path); err != nil { 18 | t.Errorf("isHiddenPath(%v) returned unexpected error: %s", path, err) 19 | } else if expected != actual { 20 | t.Errorf("isHiddenPath(%v), expected %v, got %v", path, expected, actual) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /dlna/dms/dms_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package dms 5 | 6 | import ( 7 | "path/filepath" 8 | 9 | "golang.org/x/sys/windows" 10 | ) 11 | 12 | const hiddenAttributes = windows.FILE_ATTRIBUTE_HIDDEN | windows.FILE_ATTRIBUTE_SYSTEM 13 | 14 | func isHiddenPath(path string) (hidden bool, err error) { 15 | if path == filepath.VolumeName(path)+"\\" { 16 | // Volumes always have the "SYSTEM" flag, so do not even test them 17 | return false, nil 18 | } 19 | winPath, err := windows.UTF16PtrFromString(path) 20 | if err != nil { 21 | return 22 | } 23 | attrs, err := windows.GetFileAttributes(winPath) 24 | if err != nil { 25 | return 26 | } 27 | if attrs&hiddenAttributes != 0 { 28 | hidden = true 29 | return 30 | } 31 | return isHiddenPath(filepath.Dir(path)) 32 | } 33 | 34 | func isReadablePath(path string) (bool, error) { 35 | return tryToOpenPath(path) 36 | } 37 | -------------------------------------------------------------------------------- /dlna/dms/ffmpeg.go: -------------------------------------------------------------------------------- 1 | package dms 2 | 3 | import ( 4 | "os/exec" 5 | "runtime" 6 | "syscall" 7 | ) 8 | 9 | func suppressFFmpegProbeDataErrors(_err error) (err error) { 10 | if _err == nil { 11 | return 12 | } 13 | err = _err 14 | exitErr, ok := err.(*exec.ExitError) 15 | if !ok { 16 | return 17 | } 18 | waitStat, ok := exitErr.Sys().(syscall.WaitStatus) 19 | if !ok { 20 | return 21 | } 22 | code := waitStat.ExitStatus() 23 | if runtime.GOOS == "windows" { 24 | if code == -1094995529 { 25 | err = nil 26 | } 27 | } else if code == 183 { 28 | err = nil 29 | } 30 | return 31 | } 32 | -------------------------------------------------------------------------------- /dlna/dms/html.go: -------------------------------------------------------------------------------- 1 | package dms 2 | 3 | import ( 4 | "html/template" 5 | ) 6 | 7 | var rootTmpl *template.Template 8 | 9 | func init() { 10 | rootTmpl = template.Must(template.New("root").Parse( 11 | `
12 | Path: 17 | 18 |
`)) 19 | } 20 | -------------------------------------------------------------------------------- /dlna/dms/mimetype.go: -------------------------------------------------------------------------------- 1 | package dms 2 | 3 | import ( 4 | "mime" 5 | "net/http" 6 | "os" 7 | "path" 8 | "strings" 9 | 10 | "github.com/anacrolix/log" 11 | ) 12 | 13 | func init() { 14 | if err := mime.AddExtensionType(".rmvb", "application/vnd.rn-realmedia-vbr"); err != nil { 15 | log.Printf("Could not register application/vnd.rn-realmedia-vbr MIME type: %s", err) 16 | } 17 | if err := mime.AddExtensionType(".ogv", "video/ogg"); err != nil { 18 | log.Printf("Could not register video/ogg MIME type: %s", err) 19 | } 20 | if err := mime.AddExtensionType(".ogg", "audio/ogg"); err != nil { 21 | log.Printf("Could not register audio/ogg MIME type: %s", err) 22 | } 23 | } 24 | 25 | // Example: "video/mpeg" 26 | type mimeType string 27 | 28 | // IsMedia returns true for media MIME-types 29 | func (mt mimeType) IsMedia() bool { 30 | return mt.IsVideo() || mt.IsAudio() || mt.IsImage() 31 | } 32 | 33 | // IsVideo returns true for video MIME-types 34 | func (mt mimeType) IsVideo() bool { 35 | return strings.HasPrefix(string(mt), "video/") || mt == "application/vnd.rn-realmedia-vbr" 36 | } 37 | 38 | // IsAudio returns true for audio MIME-types 39 | func (mt mimeType) IsAudio() bool { 40 | return strings.HasPrefix(string(mt), "audio/") 41 | } 42 | 43 | // IsImage returns true for image MIME-types 44 | func (mt mimeType) IsImage() bool { 45 | return strings.HasPrefix(string(mt), "image/") 46 | } 47 | 48 | // Returns the group "type", the part before the '/'. 49 | func (mt mimeType) Type() string { 50 | return strings.SplitN(string(mt), "/", 2)[0] 51 | } 52 | 53 | // Returns the string representation of this MIME-type 54 | func (mt mimeType) String() string { 55 | return string(mt) 56 | } 57 | 58 | // MimeTypeByPath determines the MIME-type of file at the given path 59 | func MimeTypeByPath(filePath string) (ret mimeType, err error) { 60 | ret = mimeTypeByBaseName(path.Base(filePath)) 61 | if ret == "" { 62 | ret, err = mimeTypeByContent(filePath) 63 | } 64 | if ret == "video/x-msvideo" { 65 | ret = "video/avi" 66 | } else if ret == "" { 67 | ret = "application/octet-stream" 68 | } 69 | return 70 | } 71 | 72 | // Guess MIME-type from the extension, ignoring ".part". 73 | func mimeTypeByBaseName(name string) mimeType { 74 | name = strings.TrimSuffix(name, ".part") 75 | ext := path.Ext(name) 76 | if ext != "" { 77 | return mimeType(mime.TypeByExtension(ext)) 78 | } 79 | return mimeType("") 80 | } 81 | 82 | // Guess the MIME-type by analysing the first 512 bytes of the file. 83 | func mimeTypeByContent(path string) (ret mimeType, err error) { 84 | file, err := os.Open(path) 85 | if err != nil { 86 | return 87 | } 88 | defer file.Close() 89 | var data [512]byte 90 | if n, err := file.Read(data[:]); err == nil { 91 | ret = mimeType(http.DetectContentType(data[:n])) 92 | } 93 | return 94 | } 95 | -------------------------------------------------------------------------------- /dlna/dms/mrrs-desc.go: -------------------------------------------------------------------------------- 1 | package dms 2 | 3 | const mediaReceiverRegistrarDescription = ` 4 | 5 | 6 | 1 7 | 0 8 | 9 | 10 | 11 | IsAuthorized 12 | 13 | 14 | DeviceID 15 | in 16 | A_ARG_TYPE_DeviceID 17 | 18 | 19 | Result 20 | out 21 | A_ARG_TYPE_Result 22 | 23 | 24 | 25 | 26 | RegisterDevice 27 | 28 | 29 | RegistrationReqMsg 30 | in 31 | A_ARG_TYPE_RegistrationReqMsg 32 | 33 | 34 | RegistrationRespMsg 35 | out 36 | A_ARG_TYPE_RegistrationRespMsg 37 | 38 | 39 | 40 | 41 | IsValidated 42 | 43 | 44 | DeviceID 45 | in 46 | A_ARG_TYPE_DeviceID 47 | 48 | 49 | Result 50 | out 51 | A_ARG_TYPE_Result 52 | 53 | 54 | 55 | 56 | 57 | 58 | A_ARG_TYPE_DeviceID 59 | string 60 | 61 | 62 | A_ARG_TYPE_Result 63 | int 64 | 65 | 66 | A_ARG_TYPE_RegistrationReqMsg 67 | bin.base64 68 | 69 | 70 | A_ARG_TYPE_RegistrationRespMsg 71 | bin.base64 72 | 73 | 74 | AuthorizationGrantedUpdateID 75 | ui4 76 | 77 | 78 | AuthorizationDeniedUpdateID 79 | ui4 80 | 81 | 82 | ValidationSucceededUpdateID 83 | ui4 84 | 85 | 86 | ValidationRevokedUpdateID 87 | ui4 88 | 89 | 90 | ` 91 | -------------------------------------------------------------------------------- /dlna/dms/mrrs.go: -------------------------------------------------------------------------------- 1 | package dms 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/anacrolix/dms/upnp" 7 | ) 8 | 9 | type mediaReceiverRegistrarService struct { 10 | *Server 11 | upnp.Eventing 12 | } 13 | 14 | func (mrrs *mediaReceiverRegistrarService) Handle(action string, argsXML []byte, r *http.Request) ([][2]string, error) { 15 | switch action { 16 | case "IsAuthorized", "IsValidated": 17 | return [][2]string{ 18 | {"Result", "1"}, 19 | }, nil 20 | case "RegisterDevice": 21 | return [][2]string{ 22 | {"RegistrationRespMsg", mrrs.rootDeviceUUID}, 23 | }, nil 24 | // return nil, nil 25 | default: 26 | return nil, upnp.InvalidActionError 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/anacrolix/dms 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/anacrolix/ffprobe v1.1.0 7 | github.com/anacrolix/log v0.15.2 8 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 9 | golang.org/x/net v0.26.0 10 | golang.org/x/sys v0.21.0 11 | ) 12 | 13 | require ( 14 | github.com/anacrolix/generics v0.0.1 // indirect 15 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w= 2 | github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= 3 | github.com/anacrolix/envpprof v1.0.0 h1:AwZ+mBP4rQ5f7JSsrsN3h7M2xDW/xSE66IPVOqlnuUc= 4 | github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= 5 | github.com/anacrolix/ffprobe v1.0.0 h1:j8fGLBsXejwdXd0pkA9iR3Dt1XwMFv5wjeYWObcue8A= 6 | github.com/anacrolix/ffprobe v1.0.0/go.mod h1:BIw+Bjol6CWjm/CRWrVLk2Vy+UYlkgmBZ05vpSYqZPw= 7 | github.com/anacrolix/ffprobe v1.1.0 h1:eKBudnERW9zRJ0+ge6FzkQ0pWLyq142+FJrwRwSRMT4= 8 | github.com/anacrolix/ffprobe v1.1.0/go.mod h1:MXe+zG/RRa5OdIf5+VYYfS/CfsSqOH7RrvGIqJBzqhI= 9 | github.com/anacrolix/generics v0.0.0-20230113004304-d6428d516633 h1:TO3pytMIJ98CO1nYtqbFx/iuTHi4OgIUoE2wNfDdKxw= 10 | github.com/anacrolix/generics v0.0.0-20230113004304-d6428d516633/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8= 11 | github.com/anacrolix/generics v0.0.1 h1:4WVhK6iLb3UAAAQP6I3uYlMOHcp9FqJC9j4n81Wv9Ks= 12 | github.com/anacrolix/generics v0.0.1/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8= 13 | github.com/anacrolix/log v0.15.2 h1:LTSf5Wm6Q4GNWPFMBP7NPYV6UBVZzZLKckL+/Lj72Oo= 14 | github.com/anacrolix/log v0.15.2/go.mod h1:m0poRtlr41mriZlXBQ9SOVZ8yZBkLjOkDhd5Li5pITA= 15 | github.com/anacrolix/missinggo v1.1.0 h1:0lZbaNa6zTR1bELAIzCNmRGAtkHuLDPJqTiTtXoAIx8= 16 | github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo= 17 | github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= 18 | github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2 h1:1B/+1BcRhOMG1KH/YhNIU8OppSWk5d/NGyfRla88CuY= 19 | github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= 20 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 22 | github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= 23 | github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 24 | github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= 25 | github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= 26 | github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= 27 | github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 28 | github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 29 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 30 | github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 31 | github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= 32 | github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 33 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 34 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 35 | github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= 36 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 37 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 38 | github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= 39 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 40 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 41 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 42 | github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= 43 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 44 | github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= 45 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 46 | github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 47 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 48 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 49 | github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= 50 | github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= 51 | golang.org/x/exp v0.0.0-20220428152302-39d4317da171 h1:TfdoLivD44QwvssI9Sv1xwa5DcL5XQr4au4sZ2F2NV4= 52 | golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= 53 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= 54 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= 55 | golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= 56 | golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 57 | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 58 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 59 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 60 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 61 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 62 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 63 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 64 | -------------------------------------------------------------------------------- /helpers/bsd/dms: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . /etc/rc.subr 4 | 5 | name="dms" 6 | rcvar="dms_enable" 7 | 8 | : ${dms_user:="root"} 9 | : ${dms_enable:="NO"} 10 | : ${dms_media_dir:="/media"} 11 | 12 | # Daemon 13 | pidfile="/var/run/${name}.pid" 14 | command=/usr/sbin/daemon 15 | procname="daemon" 16 | dms="/usr/local/bin/dms -path ${dms_media_dir}" 17 | command_args=" -P ${pidfile} -r -f -u ${dms_user} ${dms}" 18 | start_precmd="dms_precmd" 19 | pidfile="/var/run/${name}.pid" 20 | 21 | dms_precmd() 22 | { 23 | install -o ${dms_user} /dev/null ${pidfile} 24 | } 25 | 26 | load_rc_config $name 27 | run_rc_command "$1" 28 | -------------------------------------------------------------------------------- /helpers/systemd/dms.service: -------------------------------------------------------------------------------- 1 | # Put this file in /home/USERNAME/.config/systemd/user/ 2 | # 3 | # Enable this service with 4 | # systemctl --user --now enable dms.service 5 | [Unit] 6 | Description=DMS UPnP Media Server 7 | 8 | [Service] 9 | ExecStart=/home/USERNAME/go/bin/dms -friendlyName DMS_Server -path /home/share/ 10 | 11 | [Install] 12 | WantedBy=default.target 13 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "encoding/json" 7 | "flag" 8 | "fmt" 9 | "image" 10 | "image/png" 11 | "io" 12 | "io/ioutil" 13 | "net" 14 | "os" 15 | "os/signal" 16 | "os/user" 17 | "path/filepath" 18 | "runtime" 19 | "strconv" 20 | "strings" 21 | "sync" 22 | "syscall" 23 | "time" 24 | 25 | "github.com/anacrolix/log" 26 | "github.com/nfnt/resize" 27 | 28 | "github.com/anacrolix/dms/dlna/dms" 29 | "github.com/anacrolix/dms/rrcache" 30 | ) 31 | 32 | //go:embed "data/VGC Sonic.png" 33 | var defaultIcon []byte 34 | 35 | type dmsConfig struct { 36 | Path string 37 | IfName string 38 | Http string 39 | FriendlyName string 40 | DeviceIcon string 41 | DeviceIconSizes []string 42 | LogHeaders bool 43 | FFprobeCachePath string 44 | NoTranscode bool 45 | ForceTranscodeTo string 46 | NoProbe bool 47 | StallEventSubscribe bool 48 | NotifyInterval time.Duration 49 | IgnoreHidden bool 50 | IgnoreUnreadable bool 51 | IgnorePaths []string 52 | AllowedIpNets []*net.IPNet 53 | AllowDynamicStreams bool 54 | TranscodeLogPattern string 55 | } 56 | 57 | func (config *dmsConfig) load(configPath string) { 58 | file, err := os.Open(configPath) 59 | if err != nil { 60 | log.Printf("config error (config file: '%s'): %v\n", configPath, err) 61 | return 62 | } 63 | defer file.Close() 64 | decoder := json.NewDecoder(file) 65 | err = decoder.Decode(&config) 66 | if err != nil { 67 | log.Printf("config error: %v\n", err) 68 | return 69 | } 70 | } 71 | 72 | // default config 73 | var config = &dmsConfig{ 74 | Path: "", 75 | IfName: "", 76 | Http: ":1338", 77 | FriendlyName: "", 78 | DeviceIcon: "", 79 | DeviceIconSizes: []string{"48,128"}, 80 | LogHeaders: false, 81 | FFprobeCachePath: getDefaultFFprobeCachePath(), 82 | ForceTranscodeTo: "", 83 | } 84 | 85 | func getDefaultFFprobeCachePath() (path string) { 86 | _user, err := user.Current() 87 | if err != nil { 88 | log.Print(err) 89 | return 90 | } 91 | path = filepath.Join(_user.HomeDir, ".dms-ffprobe-cache") 92 | return 93 | } 94 | 95 | type fFprobeCache struct { 96 | c *rrcache.RRCache 97 | sync.Mutex 98 | } 99 | 100 | func (fc *fFprobeCache) Get(key interface{}) (value interface{}, ok bool) { 101 | fc.Lock() 102 | defer fc.Unlock() 103 | return fc.c.Get(key) 104 | } 105 | 106 | func (fc *fFprobeCache) Set(key interface{}, value interface{}) { 107 | fc.Lock() 108 | defer fc.Unlock() 109 | var size int64 110 | for _, v := range []interface{}{key, value} { 111 | b, err := json.Marshal(v) 112 | if err != nil { 113 | log.Printf("Could not marshal %v: %s", v, err) 114 | continue 115 | } 116 | size += int64(len(b)) 117 | } 118 | fc.c.Set(key, value, size) 119 | } 120 | 121 | func main() { 122 | err := mainErr() 123 | if err != nil { 124 | log.Fatalf("error in main: %v", err) 125 | } 126 | } 127 | 128 | func mainErr() error { 129 | path := flag.String("path", config.Path, "browse root path") 130 | ifName := flag.String("ifname", config.IfName, "specific SSDP network interface") 131 | http := flag.String("http", config.Http, "http server port") 132 | friendlyName := flag.String("friendlyName", config.FriendlyName, "server friendly name") 133 | deviceIcon := flag.String("deviceIcon", config.DeviceIcon, "device defaultIcon") 134 | deviceIconSizes := flag.String("deviceIconSizes", strings.Join(config.DeviceIconSizes, ","), "comma separated list of icon sizes to advertise, eg 48,128,256. Use 48:512,128:512 format to force actual size.") 135 | logHeaders := flag.Bool("logHeaders", config.LogHeaders, "log HTTP headers") 136 | fFprobeCachePath := flag.String("fFprobeCachePath", config.FFprobeCachePath, "path to FFprobe cache file") 137 | configFilePath := flag.String("config", "", "json configuration file") 138 | allowedIps := flag.String("allowedIps", "", "allowed ip of clients, separated by comma") 139 | forceTranscodeTo := flag.String("forceTranscodeTo", config.ForceTranscodeTo, "force transcoding to certain format, supported: 'chromecast', 'vp8', 'web'") 140 | transcodeLogPattern := flag.String("transcodeLogPattern", "", "pattern where to write transcode logs to. The [tsname] placeholder is replaced with the name of the item currently being played. The default is $HOME/.dms/log/[tsname]") 141 | flag.BoolVar(&config.NoTranscode, "noTranscode", false, "disable transcoding") 142 | flag.BoolVar(&config.NoProbe, "noProbe", false, "disable media probing with ffprobe") 143 | flag.BoolVar(&config.StallEventSubscribe, "stallEventSubscribe", false, "workaround for some bad event subscribers") 144 | flag.DurationVar(&config.NotifyInterval, "notifyInterval", 30*time.Second, "interval between SSPD announces") 145 | flag.BoolVar(&config.IgnoreHidden, "ignoreHidden", false, "ignore hidden files and directories") 146 | flag.BoolVar(&config.IgnoreUnreadable, "ignoreUnreadable", false, "ignore unreadable files and directories") 147 | ignorePaths := flag.String("ignore", "", "comma separated list of directories to ignore (i.e. thumbnails,thumbs)") 148 | flag.BoolVar(&config.AllowDynamicStreams, "allowDynamicStreams", false, "activate support for dynamic streams described via .dms.json metadata files") 149 | 150 | flag.Parse() 151 | if flag.NArg() != 0 { 152 | flag.Usage() 153 | return fmt.Errorf("%s: %s\n", "unexpected positional arguments", flag.Args()) 154 | } 155 | 156 | logger := log.Default.WithNames("main") 157 | 158 | config.Path, _ = filepath.Abs(*path) 159 | config.IfName = *ifName 160 | config.Http = *http 161 | config.FriendlyName = *friendlyName 162 | config.DeviceIcon = *deviceIcon 163 | config.DeviceIconSizes = strings.Split(*deviceIconSizes, ",") 164 | 165 | config.LogHeaders = *logHeaders 166 | config.FFprobeCachePath = *fFprobeCachePath 167 | config.AllowedIpNets = makeIpNets(*allowedIps) 168 | config.ForceTranscodeTo = *forceTranscodeTo 169 | config.IgnorePaths = strings.Split(*ignorePaths, ",") 170 | config.TranscodeLogPattern = *transcodeLogPattern 171 | 172 | if config.TranscodeLogPattern == "" { 173 | u, err := user.Current() 174 | if err != nil { 175 | return fmt.Errorf("unable to resolve current user: %q", err) 176 | } 177 | config.TranscodeLogPattern = filepath.Join(u.HomeDir, ".dms", "log", "[tsname]") 178 | } 179 | 180 | if len(*configFilePath) > 0 { 181 | config.load(*configFilePath) 182 | } 183 | 184 | logger.Printf("device icon sizes are %q", config.DeviceIconSizes) 185 | logger.Printf("allowed ip nets are %q", config.AllowedIpNets) 186 | logger.Printf("serving folder %q", config.Path) 187 | if config.AllowDynamicStreams { 188 | logger.Printf("Dynamic streams ARE allowed") 189 | } 190 | 191 | cache := &fFprobeCache{ 192 | c: rrcache.New(64 << 20), 193 | } 194 | if err := cache.load(config.FFprobeCachePath); err != nil { 195 | log.Print(err) 196 | } 197 | 198 | dmsServer := &dms.Server{ 199 | Logger: logger.WithNames("dms", "server"), 200 | Interfaces: func(ifName string) (ifs []net.Interface) { 201 | var err error 202 | if ifName == "" { 203 | ifs, err = net.Interfaces() 204 | } else { 205 | var if_ *net.Interface 206 | if_, err = net.InterfaceByName(ifName) 207 | if if_ != nil { 208 | ifs = append(ifs, *if_) 209 | } 210 | } 211 | if err != nil { 212 | log.Fatal(err) 213 | } 214 | var tmp []net.Interface 215 | for _, if_ := range ifs { 216 | if if_.Flags&net.FlagUp == 0 || if_.MTU <= 0 { 217 | continue 218 | } 219 | tmp = append(tmp, if_) 220 | } 221 | ifs = tmp 222 | return 223 | }(config.IfName), 224 | HTTPConn: func() net.Listener { 225 | conn, err := net.Listen("tcp", config.Http) 226 | if err != nil { 227 | log.Fatal(err) 228 | } 229 | return conn 230 | }(), 231 | FriendlyName: config.FriendlyName, 232 | RootObjectPath: filepath.Clean(config.Path), 233 | FFProbeCache: cache, 234 | LogHeaders: config.LogHeaders, 235 | NoTranscode: config.NoTranscode, 236 | AllowDynamicStreams: config.AllowDynamicStreams, 237 | ForceTranscodeTo: config.ForceTranscodeTo, 238 | TranscodeLogPattern: config.TranscodeLogPattern, 239 | NoProbe: config.NoProbe, 240 | Icons: func() []dms.Icon { 241 | var icons []dms.Icon 242 | for _, size := range config.DeviceIconSizes { 243 | s := strings.Split(size, ":") 244 | if len(s) != 1 && len(s) != 2 { 245 | log.Fatal("bad device icon size: ", size) 246 | } 247 | advertisedSize, err := strconv.Atoi(s[0]) 248 | if err != nil { 249 | log.Fatal("bad device icon size: ", size) 250 | } 251 | actualSize := advertisedSize 252 | if len(s) == 2 { 253 | // Force actual icon size to be different from advertised 254 | actualSize, err = strconv.Atoi(s[1]) 255 | if err != nil { 256 | log.Fatal("bad device icon size: ", size) 257 | } 258 | } 259 | icons = append(icons, dms.Icon{ 260 | Width: advertisedSize, 261 | Height: advertisedSize, 262 | Depth: 8, 263 | Mimetype: "image/png", 264 | Bytes: readIcon(config.DeviceIcon, uint(actualSize)), 265 | }) 266 | } 267 | return icons 268 | }(), 269 | StallEventSubscribe: config.StallEventSubscribe, 270 | NotifyInterval: config.NotifyInterval, 271 | IgnoreHidden: config.IgnoreHidden, 272 | IgnoreUnreadable: config.IgnoreUnreadable, 273 | IgnorePaths: config.IgnorePaths, 274 | AllowedIpNets: config.AllowedIpNets, 275 | } 276 | if err := dmsServer.Init(); err != nil { 277 | log.Fatalf("error initing dms server: %v", err) 278 | } 279 | go func() { 280 | if err := dmsServer.Run(); err != nil { 281 | log.Fatal(err) 282 | } 283 | }() 284 | sigs := make(chan os.Signal, 1) 285 | signal.Notify(sigs, os.Interrupt, syscall.SIGTERM) 286 | <-sigs 287 | err := dmsServer.Close() 288 | if err != nil { 289 | log.Fatal(err) 290 | } 291 | if err := cache.save(config.FFprobeCachePath); err != nil { 292 | log.Print(err) 293 | } 294 | return nil 295 | } 296 | 297 | func (cache *fFprobeCache) load(path string) error { 298 | f, err := os.Open(path) 299 | if err != nil { 300 | return err 301 | } 302 | defer f.Close() 303 | dec := json.NewDecoder(f) 304 | var items []dms.FfprobeCacheItem 305 | err = dec.Decode(&items) 306 | if err != nil { 307 | return err 308 | } 309 | for _, item := range items { 310 | cache.Set(item.Key, item.Value) 311 | } 312 | log.Printf("added %d items from cache", len(items)) 313 | return nil 314 | } 315 | 316 | func (cache *fFprobeCache) save(path string) error { 317 | cache.Lock() 318 | items := cache.c.Items() 319 | cache.Unlock() 320 | f, err := ioutil.TempFile(filepath.Dir(path), filepath.Base(path)) 321 | if err != nil { 322 | return err 323 | } 324 | enc := json.NewEncoder(f) 325 | err = enc.Encode(items) 326 | f.Close() 327 | if err != nil { 328 | os.Remove(f.Name()) 329 | return err 330 | } 331 | if runtime.GOOS == "windows" { 332 | err = os.Remove(path) 333 | if err == os.ErrNotExist { 334 | err = nil 335 | } 336 | } 337 | if err == nil { 338 | err = os.Rename(f.Name(), path) 339 | } 340 | if err == nil { 341 | log.Printf("saved cache with %d items", len(items)) 342 | } else { 343 | os.Remove(f.Name()) 344 | } 345 | return err 346 | } 347 | 348 | func getIconReader(path string) (io.ReadCloser, error) { 349 | if path == "" { 350 | return ioutil.NopCloser(bytes.NewReader(defaultIcon)), nil 351 | } 352 | return os.Open(path) 353 | } 354 | 355 | func readIcon(path string, size uint) []byte { 356 | r, err := getIconReader(path) 357 | if err != nil { 358 | panic(err) 359 | } 360 | defer r.Close() 361 | imageData, _, err := image.Decode(r) 362 | if err != nil { 363 | panic(err) 364 | } 365 | return resizeImage(imageData, size) 366 | } 367 | 368 | func resizeImage(imageData image.Image, size uint) []byte { 369 | img := resize.Resize(size, size, imageData, resize.Lanczos3) 370 | var buff bytes.Buffer 371 | png.Encode(&buff, img) 372 | return buff.Bytes() 373 | } 374 | 375 | func makeIpNets(s string) []*net.IPNet { 376 | var nets []*net.IPNet 377 | if len(s) < 1 { 378 | _, ipnet, _ := net.ParseCIDR("0.0.0.0/0") 379 | nets = append(nets, ipnet) 380 | _, ipnet, _ = net.ParseCIDR("::/0") 381 | nets = append(nets, ipnet) 382 | } else { 383 | for _, el := range strings.Split(s, ",") { 384 | ip := net.ParseIP(el) 385 | 386 | if ip == nil { 387 | _, ipnet, err := net.ParseCIDR(el) 388 | if err == nil { 389 | nets = append(nets, ipnet) 390 | } else { 391 | log.Printf("unable to parse expression %q", el) 392 | } 393 | 394 | } else { 395 | _, ipnet, err := net.ParseCIDR(el + "/32") 396 | if err == nil { 397 | nets = append(nets, ipnet) 398 | } else { 399 | log.Printf("unable to parse ip %q", el) 400 | } 401 | } 402 | } 403 | } 404 | return nets 405 | } 406 | -------------------------------------------------------------------------------- /misc/dms-win32/NOTES: -------------------------------------------------------------------------------- 1 | This directory should contain a script to build the "dms-win32" package. It's an archive containing the DMS GUI components and all necessary libraries. 2 | 3 | The script should get the latest static executable builds of ffmpeg, dms, and GTK+ all-in-one into a single folder, with executable files in bin/, and user-facing utilities from this directory in the root, and compress the lot. 4 | -------------------------------------------------------------------------------- /misc/dms-win32/README.txt: -------------------------------------------------------------------------------- 1 | Double click the batch file "dms-gui". 2 | 3 | Select a media directory to share, and enjoy from a DLNA/UPnP enabled device. 4 | 5 | Try BubbleUPnP from an Android, or VLC's "Local Network>UPnP" interface. -------------------------------------------------------------------------------- /misc/dms-win32/dms-gui.bat: -------------------------------------------------------------------------------- 1 | set PATH=%~dp0\bin 2 | start dms-gtk-gui.exe -------------------------------------------------------------------------------- /misc/misc.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | func FormatDurationSexagesimal(d time.Duration) string { 10 | ns := d % time.Second 11 | d /= time.Second 12 | s := d % 60 13 | d /= 60 14 | m := d % 60 15 | d /= 60 16 | h := d 17 | ret := fmt.Sprintf("%d:%02d:%02d.%09d", h, m, s, ns) 18 | ret = strings.TrimRight(ret, "0") 19 | ret = strings.TrimRight(ret, ".") 20 | return ret 21 | } 22 | -------------------------------------------------------------------------------- /misc/misc_test.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestFormatDurationSexagesimal(t *testing.T) { 9 | expected := "0:22:57.628452" 10 | actual := FormatDurationSexagesimal(time.Duration(1377628452000)) 11 | if actual != expected { 12 | t.Fatalf("got %q, expected %q", actual, expected) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /play/attrs.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "encoding/xml" 8 | "fmt" 9 | ) 10 | 11 | type Meh struct { 12 | XMLName xml.Name 13 | Size 14 | // ChildCount *uint `xml:"childCount,attr"` 15 | } 16 | 17 | func main() { 18 | size := uint64(137) 19 | data, err := xml.Marshal(Meh{ 20 | Size: &xml.Attr{ 21 | Name: xml.Name{Local: "size"}, 22 | Value: fmt.Sprint(size), 23 | }, 24 | }) 25 | fmt.Println(string(data), err) 26 | } 27 | -------------------------------------------------------------------------------- /play/bool.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import "fmt" 7 | 8 | func main() { 9 | fmt.Printf("%q%c\n", 3, false) 10 | } 11 | -------------------------------------------------------------------------------- /play/browse.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 0 6 | BrowseDirectChildren 7 | dc:title,dc:date,res,res@protocolInfo,res@size,res@duration,res@resolution,res@dlna:ifoFileURI,res@pv:subtitleFileType,res@pv:subtitleFileUri,upnp:albumArtURI,upnp:album,upnp:artist 8 | 0 9 | 20 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /play/closure.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | ) 9 | 10 | func main() { 11 | ch := make(chan struct{}) 12 | for i := 0; i < 5; i++ { 13 | j := i 14 | go func() { 15 | fmt.Print(j) 16 | ch <- struct{}{} 17 | }() 18 | } 19 | for i := 5; i < 10; i++ { 20 | go func() { 21 | fmt.Print(i) 22 | ch <- struct{}{} 23 | }() 24 | } 25 | for i := 0; i < 10; i++ { 26 | <-ch 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /play/execbug.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "io" 8 | "os" 9 | "os/exec" 10 | "time" 11 | ) 12 | 13 | func main() { 14 | cmd := exec.Command("ls") 15 | out, err := cmd.StdoutPipe() 16 | if err != nil { 17 | panic(err) 18 | } 19 | if err = cmd.Start(); err != nil { 20 | panic(err) 21 | } 22 | go cmd.Wait() 23 | time.Sleep(10 * time.Millisecond) 24 | _, err = io.Copy(os.Stdout, out) 25 | if err != nil { 26 | panic(err) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /play/execgood.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "io" 8 | "os" 9 | "os/exec" 10 | "time" 11 | 12 | "github.com/anacrolix/log" 13 | ) 14 | 15 | func main() { 16 | cmd := exec.Command("ls") 17 | out, err := cmd.StdoutPipe() 18 | if err != nil { 19 | panic(err) 20 | } 21 | if err := cmd.Start(); err != nil { 22 | panic(err) 23 | } 24 | r, w := io.Pipe() 25 | go func() { 26 | io.Copy(w, out) 27 | out.Close() 28 | w.Close() 29 | log.Println(cmd.Wait()) 30 | }() 31 | time.Sleep(10 * time.Millisecond) 32 | if _, err := io.Copy(os.Stdout, r); err != nil { 33 | panic(err) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /play/ffprobe.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "flag" 8 | 9 | "github.com/anacrolix/ffprobe" 10 | "github.com/anacrolix/log" 11 | ) 12 | 13 | func main() { 14 | log.SetFlags(log.Llongfile) 15 | flag.Parse() 16 | for _, path := range flag.Args() { 17 | i, err := ffprobe.Probe(path) 18 | log.Printf("%#v %#v", i, err) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /play/getsortcaps.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /play/mime.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "flag" 8 | "fmt" 9 | 10 | "github.com/anacrolix/dms/dlna/dms" 11 | ) 12 | 13 | func main() { 14 | flag.Parse() 15 | for _, arg := range flag.Args() { 16 | fmt.Println(dms.MimeTypeByPath(arg)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /play/parse_http_version.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "net/http" 9 | "strings" 10 | ) 11 | 12 | func main() { 13 | fmt.Println(http.ParseHTTPVersion(strings.TrimSpace("HTTP/1.1 "))) 14 | } 15 | -------------------------------------------------------------------------------- /play/print-ifs.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "net" 9 | ) 10 | 11 | func main() { 12 | ifs, err := net.Interfaces() 13 | if err != nil { 14 | panic(err) 15 | } 16 | for _, if_ := range ifs { 17 | fmt.Printf("%#v\n", if_) 18 | addrs, err := if_.Addrs() 19 | if err != nil { 20 | panic(err) 21 | } 22 | for _, addr := range addrs { 23 | fmt.Printf("\t%s %s\n", addr.Network(), addr) 24 | } 25 | mcastAddrs, err := if_.MulticastAddrs() 26 | if err != nil { 27 | panic(err) 28 | } 29 | for _, addr := range mcastAddrs { 30 | fmt.Printf("\t%s\n", addr) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /play/scpd.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "encoding/xml" 8 | "fmt" 9 | 10 | "github.com/anacrolix/dms/upnp" 11 | "github.com/anacrolix/log" 12 | ) 13 | 14 | func main() { 15 | scpd := upnp.SCPD{ 16 | SpecVersion: upnp.SpecVersion{Major: 1, Minor: 0}, 17 | ActionList: []upnp.Action{ 18 | { 19 | Name: "Browse", 20 | Arguments: []upnp.Argument{ 21 | {Name: "ObjectID", Direction: "in", RelatedStateVar: "A_ARG_TYPE_ObjectID"}, 22 | }, 23 | }, 24 | }, 25 | ServiceStateTable: []upnp.StateVariable{ 26 | { 27 | SendEvents: "no", Name: "A_ARG_TYPE_ObjectID", DataType: "string", 28 | AllowedValues: &[]string{"hi", "there"}, 29 | }, 30 | { 31 | SendEvents: "yes", 32 | Name: "loltype", 33 | }, 34 | }, 35 | } 36 | xml, err := xml.MarshalIndent(scpd, "", " ") 37 | if err != nil { 38 | log.Fatalln(err) 39 | } 40 | fmt.Print(string(xml)) 41 | } 42 | -------------------------------------------------------------------------------- /play/soap.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "encoding/xml" 8 | "fmt" 9 | "io/ioutil" 10 | "os" 11 | 12 | "github.com/anacrolix/dms/soap" 13 | ) 14 | 15 | type Browse struct { 16 | ObjectID string 17 | BrowseFlag string 18 | Filter string 19 | StartingIndex int 20 | RequestedCount int 21 | } 22 | 23 | type GetSortCapabilitiesResponse struct { 24 | XMLName xml.Name `xml:"urn:schemas-upnp-org:service:ContentDirectory:1 GetSortCapabilitiesResponse"` 25 | SortCaps string 26 | } 27 | 28 | func main() { 29 | raw, err := ioutil.ReadAll(os.Stdin) 30 | if err != nil { 31 | panic(err) 32 | } 33 | var env soap.Envelope 34 | if err := xml.Unmarshal(raw, &env); err != nil { 35 | panic(err) 36 | } 37 | fmt.Println(env) 38 | var browse Browse 39 | err = xml.Unmarshal([]byte(env.Body.Action), &browse) 40 | if err != nil { 41 | panic(err) 42 | } 43 | fmt.Println(browse) 44 | raw, err = xml.MarshalIndent( 45 | GetSortCapabilitiesResponse{ 46 | SortCaps: "dc:title", 47 | }, 48 | "", " ") 49 | if err != nil { 50 | panic(err) 51 | } 52 | fmt.Println(string(raw)) 53 | } 54 | -------------------------------------------------------------------------------- /play/termsig/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | ) 8 | 9 | func main() { 10 | c := make(chan os.Signal, 0x100) 11 | signal.Notify(c) 12 | for i := range c { 13 | fmt.Println(i) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /play/transcode.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "bufio" 8 | "flag" 9 | "io" 10 | "os" 11 | "time" 12 | 13 | "github.com/anacrolix/log" 14 | 15 | "github.com/anacrolix/dms/misc" 16 | ) 17 | 18 | func main() { 19 | ss := flag.String("ss", "", "") 20 | t := flag.String("t", "", "") 21 | flag.Parse() 22 | if flag.NArg() != 1 { 23 | log.Fatalln("wrong argument count") 24 | } 25 | r, err := misc.Transcode(flag.Arg(0), *ss, *t) 26 | if err != nil { 27 | log.Fatalln(err) 28 | } 29 | go func() { 30 | buf := bufio.NewWriterSize(os.Stdout, 1234) 31 | n, err := io.Copy(buf, r) 32 | log.Println("copied", n, "bytes") 33 | if err != nil { 34 | log.Println(err) 35 | } 36 | }() 37 | time.Sleep(time.Second) 38 | go r.Close() 39 | time.Sleep(time.Second) 40 | } 41 | -------------------------------------------------------------------------------- /play/url.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | // +build ignore 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "net/url" 9 | 10 | "github.com/anacrolix/log" 11 | ) 12 | 13 | func main() { 14 | url_, err := url.Parse("[192:168:26:2::3]:1900") 15 | if err != nil { 16 | log.Fatalln(err) 17 | } 18 | fmt.Printf("%#v\n", url_) 19 | } 20 | -------------------------------------------------------------------------------- /rrcache/rrcache.go: -------------------------------------------------------------------------------- 1 | // Package rrcache implements a random replacement cache. Items are set with 2 | // an associated size. When the capacity is exceeded, items will be randomly 3 | // evicted until it is not. 4 | package rrcache 5 | 6 | import ( 7 | "math/rand" 8 | ) 9 | 10 | type RRCache struct { 11 | capacity int64 12 | size int64 13 | 14 | keys []interface{} 15 | table map[interface{}]*entry 16 | } 17 | 18 | type entry struct { 19 | size int64 20 | value interface{} 21 | } 22 | 23 | func New(capacity int64) *RRCache { 24 | return &RRCache{ 25 | capacity: capacity, 26 | table: make(map[interface{}]*entry), 27 | } 28 | } 29 | 30 | // Returns the sum size of all items currently in the cache. 31 | func (c *RRCache) Size() int64 { 32 | return c.size 33 | } 34 | 35 | func (c *RRCache) Set(key interface{}, value interface{}, size int64) { 36 | if size > c.capacity { 37 | return 38 | } 39 | _entry := c.table[key] 40 | if _entry == nil { 41 | _entry = new(entry) 42 | c.keys = append(c.keys, key) 43 | c.table[key] = _entry 44 | } 45 | sizeDelta := size - _entry.size 46 | _entry.value = value 47 | _entry.size = size 48 | c.size += sizeDelta 49 | for c.size > c.capacity { 50 | i := rand.Intn(len(c.keys)) 51 | key := c.keys[i] 52 | c.keys[i] = c.keys[len(c.keys)-1] 53 | c.keys = c.keys[:len(c.keys)-1] 54 | c.size -= c.table[key].size 55 | delete(c.table, key) 56 | } 57 | } 58 | 59 | func (c *RRCache) Get(key interface{}) (value interface{}, ok bool) { 60 | entry, ok := c.table[key] 61 | if !ok { 62 | return 63 | } 64 | value = entry.value 65 | return 66 | } 67 | 68 | type Item struct { 69 | Key, Value interface{} 70 | } 71 | 72 | // Return all items currently in the cache. This is made available for 73 | // serialization purposes. 74 | func (c *RRCache) Items() (itens []Item) { 75 | for k, e := range c.table { 76 | itens = append(itens, Item{k, e.value}) 77 | } 78 | return 79 | } 80 | -------------------------------------------------------------------------------- /soap/soap.go: -------------------------------------------------------------------------------- 1 | package soap 2 | 3 | import ( 4 | "encoding/xml" 5 | ) 6 | 7 | const ( 8 | EncodingStyle = "http://schemas.xmlsoap.org/soap/encoding/" 9 | EnvelopeNS = "http://schemas.xmlsoap.org/soap/envelope/" 10 | ) 11 | 12 | type Arg struct { 13 | XMLName xml.Name 14 | Value string `xml:",chardata"` 15 | } 16 | 17 | type Action struct { 18 | XMLName xml.Name 19 | Args []Arg 20 | } 21 | 22 | type Body struct { 23 | Action []byte `xml:",innerxml"` 24 | } 25 | 26 | type UPnPError struct { 27 | XMLName xml.Name `xml:"urn:schemas-upnp-org:control-1-0 UPnPError"` 28 | Code uint `xml:"errorCode"` 29 | Desc string `xml:"errorDescription"` 30 | } 31 | 32 | type FaultDetail struct { 33 | XMLName xml.Name `xml:"detail"` 34 | Data interface{} 35 | } 36 | 37 | type Fault struct { 38 | XMLName xml.Name `xml:"http://schemas.xmlsoap.org/soap/envelope/ Fault"` 39 | FaultCode string `xml:"faultcode"` 40 | FaultString string `xml:"faultstring"` 41 | Detail FaultDetail `xml:"detail"` 42 | } 43 | 44 | func NewFault(s string, detail interface{}) *Fault { 45 | return &Fault{ 46 | FaultCode: EnvelopeNS + ":Client", 47 | FaultString: s, 48 | Detail: FaultDetail{ 49 | Data: detail, 50 | }, 51 | } 52 | } 53 | 54 | type Envelope struct { 55 | XMLName xml.Name `xml:"http://schemas.xmlsoap.org/soap/envelope/ Envelope"` 56 | EncodingStyle string `xml:"encodingStyle,attr"` 57 | Body Body `xml:"http://schemas.xmlsoap.org/soap/envelope/ Body"` 58 | } 59 | 60 | /* XML marshalling of nested namespaces is broken. 61 | 62 | func NewEnvelope(action []byte) Envelope { 63 | return Envelope{ 64 | EncodingStyle: EncodingStyle, 65 | Body: Body{action}, 66 | } 67 | } 68 | */ 69 | -------------------------------------------------------------------------------- /ssdp/ssdp.go: -------------------------------------------------------------------------------- 1 | package ssdp 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "math/rand" 9 | "net" 10 | "net/http" 11 | "net/textproto" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/anacrolix/log" 17 | "golang.org/x/net/ipv4" 18 | ) 19 | 20 | const ( 21 | AddrString = "239.255.255.250:1900" 22 | rootDevice = "upnp:rootdevice" 23 | aliveNTS = "ssdp:alive" 24 | byebyeNTS = "ssdp:byebye" 25 | mxMax = 10 26 | ) 27 | 28 | var NetAddr *net.UDPAddr 29 | 30 | func init() { 31 | var err error 32 | NetAddr, err = net.ResolveUDPAddr("udp4", AddrString) 33 | if err != nil { 34 | log.Printf("Could not resolve %s: %s", AddrString, err) 35 | } 36 | } 37 | 38 | type badStringError struct { 39 | what string 40 | str string 41 | } 42 | 43 | func (e *badStringError) Error() string { return fmt.Sprintf("%s %q", e.what, e.str) } 44 | 45 | func ReadRequest(b *bufio.Reader) (req *http.Request, err error) { 46 | tp := textproto.NewReader(b) 47 | var s string 48 | if s, err = tp.ReadLine(); err != nil { 49 | return nil, err 50 | } 51 | defer func() { 52 | if err == io.EOF { 53 | err = io.ErrUnexpectedEOF 54 | } 55 | }() 56 | 57 | var f []string 58 | // TODO a split that only allows N values? 59 | if f = strings.SplitN(s, " ", 3); len(f) < 3 { 60 | return nil, &badStringError{"malformed request line", s} 61 | } 62 | if f[1] != "*" { 63 | return nil, &badStringError{"bad URL request", f[1]} 64 | } 65 | req = &http.Request{ 66 | Method: f[0], 67 | } 68 | var ok bool 69 | if req.ProtoMajor, req.ProtoMinor, ok = http.ParseHTTPVersion(strings.TrimSpace(f[2])); !ok { 70 | return nil, &badStringError{"malformed HTTP version", f[2]} 71 | } 72 | 73 | mimeHeader, err := tp.ReadMIMEHeader() 74 | if err != nil { 75 | return nil, err 76 | } 77 | req.Header = http.Header(mimeHeader) 78 | return 79 | } 80 | 81 | type Server struct { 82 | conn *net.UDPConn 83 | Interface net.Interface 84 | Server string 85 | Services []string 86 | Devices []string 87 | IPFilter func(net.IP) bool 88 | Location func(net.IP) string 89 | UUID string 90 | NotifyInterval time.Duration 91 | closed chan struct{} 92 | Logger log.Logger 93 | } 94 | 95 | func makeConn(ifi net.Interface) (ret *net.UDPConn, err error) { 96 | ret, err = net.ListenMulticastUDP("udp", &ifi, NetAddr) 97 | if err != nil { 98 | return 99 | } 100 | p := ipv4.NewPacketConn(ret) 101 | if err := p.SetMulticastTTL(2); err != nil { 102 | log.Print(err) 103 | } 104 | // if err := p.SetMulticastLoopback(true); err != nil { 105 | // log.Println(err) 106 | // } 107 | return 108 | } 109 | 110 | func (me *Server) serve() { 111 | for { 112 | size := me.Interface.MTU 113 | if size > 65536 { 114 | size = 65536 115 | } else if size <= 0 { // fix for windows with mtu 4gb 116 | size = 65536 117 | } 118 | b := make([]byte, size) 119 | n, addr, err := me.conn.ReadFromUDP(b) 120 | select { 121 | case <-me.closed: 122 | return 123 | default: 124 | } 125 | if err != nil { 126 | me.Logger.Printf("error reading from UDP socket: %s", err) 127 | break 128 | } 129 | go me.handle(b[:n], addr) 130 | } 131 | } 132 | 133 | func (me *Server) Init() (err error) { 134 | me.closed = make(chan struct{}) 135 | me.conn, err = makeConn(me.Interface) 136 | if me.IPFilter == nil { 137 | me.IPFilter = func(net.IP) bool { return true } 138 | } 139 | return 140 | } 141 | 142 | func (me *Server) Close() { 143 | close(me.closed) 144 | me.sendByeBye() 145 | me.conn.Close() 146 | } 147 | 148 | func (me *Server) Serve() (err error) { 149 | go me.serve() 150 | for { 151 | select { 152 | case <-me.closed: 153 | return 154 | default: 155 | } 156 | 157 | addrs, err := me.Interface.Addrs() 158 | if err != nil { 159 | return err 160 | } 161 | for _, addr := range addrs { 162 | ip := func() net.IP { 163 | switch val := addr.(type) { 164 | case *net.IPNet: 165 | return val.IP 166 | case *net.IPAddr: 167 | return val.IP 168 | } 169 | panic(fmt.Sprint("unexpected addr type:", addr)) 170 | }() 171 | if !me.IPFilter(ip) { 172 | continue 173 | } 174 | if ip.IsLinkLocalUnicast() { 175 | // These addresses seem to confuse VLC. Possibly there's supposed to be a zone 176 | // included in the address, but I don't see one. 177 | continue 178 | } 179 | extraHdrs := [][2]string{ 180 | {"CACHE-CONTROL", fmt.Sprintf("max-age=%d", 5*me.NotifyInterval/2/time.Second)}, 181 | {"LOCATION", me.Location(ip)}, 182 | } 183 | me.notifyAll(aliveNTS, extraHdrs) 184 | } 185 | time.Sleep(me.NotifyInterval) 186 | } 187 | } 188 | 189 | func (me *Server) usnFromTarget(target string) string { 190 | if target == me.UUID { 191 | return target 192 | } 193 | return me.UUID + "::" + target 194 | } 195 | 196 | func (me *Server) makeNotifyMessage(target, nts string, extraHdrs [][2]string) []byte { 197 | lines := [...][2]string{ 198 | {"HOST", AddrString}, 199 | {"NT", target}, 200 | {"NTS", nts}, 201 | {"SERVER", me.Server}, 202 | {"USN", me.usnFromTarget(target)}, 203 | } 204 | buf := &bytes.Buffer{} 205 | fmt.Fprint(buf, "NOTIFY * HTTP/1.1\r\n") 206 | writeHdr := func(keyValue [2]string) { 207 | fmt.Fprintf(buf, "%s: %s\r\n", keyValue[0], keyValue[1]) 208 | } 209 | for _, pair := range lines { 210 | writeHdr(pair) 211 | } 212 | for _, pair := range extraHdrs { 213 | writeHdr(pair) 214 | } 215 | fmt.Fprint(buf, "\r\n") 216 | return buf.Bytes() 217 | } 218 | 219 | func (me *Server) send(buf []byte, addr *net.UDPAddr) { 220 | if n, err := me.conn.WriteToUDP(buf, addr); err != nil { 221 | me.Logger.Printf("error writing to UDP socket: %s", err) 222 | } else if n != len(buf) { 223 | me.Logger.Printf("short write: %d/%d bytes", n, len(buf)) 224 | } 225 | } 226 | 227 | func (me *Server) delayedSend(delay time.Duration, buf []byte, addr *net.UDPAddr) { 228 | go func() { 229 | select { 230 | case <-time.After(delay): 231 | me.send(buf, addr) 232 | case <-me.closed: 233 | } 234 | }() 235 | } 236 | 237 | func (me *Server) log(args ...interface{}) { 238 | args = append([]interface{}{me.Interface.Name + ":"}, args...) 239 | me.Logger.Print(args...) 240 | } 241 | 242 | func (me *Server) sendByeBye() { 243 | for _, type_ := range me.allTypes() { 244 | buf := me.makeNotifyMessage(type_, byebyeNTS, nil) 245 | me.send(buf, NetAddr) 246 | } 247 | } 248 | 249 | func (me *Server) notifyAll(nts string, extraHdrs [][2]string) { 250 | for _, type_ := range me.allTypes() { 251 | buf := me.makeNotifyMessage(type_, nts, extraHdrs) 252 | delay := time.Duration(rand.Int63n(int64(100 * time.Millisecond))) 253 | me.delayedSend(delay, buf, NetAddr) 254 | } 255 | } 256 | 257 | func (me *Server) allTypes() (ret []string) { 258 | for _, a := range [][]string{ 259 | {rootDevice, me.UUID}, 260 | me.Devices, 261 | me.Services, 262 | } { 263 | ret = append(ret, a...) 264 | } 265 | return 266 | } 267 | 268 | func (me *Server) handle(buf []byte, sender *net.UDPAddr) { 269 | req, err := ReadRequest(bufio.NewReader(bytes.NewReader(buf))) 270 | if err != nil { 271 | me.Logger.Println(err) 272 | return 273 | } 274 | if req.Method != "M-SEARCH" || req.Header.Get("man") != `"ssdp:discover"` { 275 | return 276 | } 277 | var mx int64 278 | if req.Header.Get("Host") == AddrString { 279 | mxHeader := req.Header.Get("mx") 280 | i, err := strconv.ParseUint(mxHeader, 0, 0) 281 | if err != nil { 282 | me.Logger.Printf("Invalid mx header %q: %s", mxHeader, err) 283 | return 284 | } 285 | mx = int64(i) 286 | } 287 | // fix mx 288 | if mx <= 0 { 289 | mx = 1 290 | } 291 | if mx > mxMax { 292 | mx = mxMax 293 | } 294 | types := func(st string) []string { 295 | if st == "ssdp:all" { 296 | return me.allTypes() 297 | } 298 | for _, t := range me.allTypes() { 299 | if t == st { 300 | return []string{t} 301 | } 302 | } 303 | return nil 304 | }(req.Header.Get("st")) 305 | for _, ip := range func() (ret []net.IP) { 306 | addrs, err := me.Interface.Addrs() 307 | if err != nil { 308 | panic(err) 309 | } 310 | for _, addr := range addrs { 311 | if ip, ok := func() (net.IP, bool) { 312 | switch data := addr.(type) { 313 | case *net.IPNet: 314 | if data.Contains(sender.IP) { 315 | return data.IP, true 316 | } 317 | return nil, false 318 | case *net.IPAddr: 319 | return data.IP, true 320 | } 321 | panic(addr) 322 | }(); ok { 323 | ret = append(ret, ip) 324 | } 325 | } 326 | return 327 | }() { 328 | for _, type_ := range types { 329 | resp := me.makeResponse(ip, type_, req) 330 | delay := time.Duration(rand.Int63n(int64(time.Second) * mx)) 331 | me.delayedSend(delay, resp, sender) 332 | } 333 | } 334 | } 335 | 336 | func (me *Server) makeResponse(ip net.IP, targ string, req *http.Request) (ret []byte) { 337 | resp := &http.Response{ 338 | StatusCode: 200, 339 | ProtoMajor: 1, 340 | ProtoMinor: 1, 341 | Header: make(http.Header), 342 | Request: req, 343 | } 344 | for _, pair := range [...][2]string{ 345 | {"CACHE-CONTROL", fmt.Sprintf("max-age=%d", 5*me.NotifyInterval/2/time.Second)}, 346 | {"EXT", ""}, 347 | {"LOCATION", me.Location(ip)}, 348 | {"SERVER", me.Server}, 349 | {"ST", targ}, 350 | {"USN", me.usnFromTarget(targ)}, 351 | } { 352 | resp.Header.Set(pair[0], pair[1]) 353 | } 354 | buf := &bytes.Buffer{} 355 | if err := resp.Write(buf); err != nil { 356 | panic(err) 357 | } 358 | return buf.Bytes() 359 | } 360 | -------------------------------------------------------------------------------- /transcode/transcode.go: -------------------------------------------------------------------------------- 1 | // Package transcode implements routines for transcoding to various kinds of 2 | // receiver. 3 | package transcode 4 | 5 | import ( 6 | "fmt" 7 | "io" 8 | "os/exec" 9 | "runtime" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/anacrolix/ffprobe" 14 | "github.com/anacrolix/log" 15 | 16 | . "github.com/anacrolix/dms/misc" 17 | ) 18 | 19 | // Invokes an external command and returns a reader from its stdout. The 20 | // command is waited on asynchronously. 21 | func transcodePipe(args []string, stderr io.Writer) (r io.ReadCloser, err error) { 22 | log.Println("transcode command:", args) 23 | cmd := exec.Command(args[0], args[1:]...) 24 | cmd.Stderr = stderr 25 | r, err = cmd.StdoutPipe() 26 | if err != nil { 27 | return 28 | } 29 | err = cmd.Start() 30 | if err != nil { 31 | return 32 | } 33 | go func() { 34 | err := cmd.Wait() 35 | if err != nil { 36 | log.Printf("command %s failed: %s", args, err) 37 | } 38 | }() 39 | return 40 | } 41 | 42 | // Return a series of ffmpeg arguments that pick specific codecs for specific 43 | // streams. This requires use of the -map flag. 44 | func streamArgs(s map[string]interface{}) (ret []string) { 45 | defer func() { 46 | if len(ret) != 0 { 47 | ret = append(ret, []string{ 48 | "-map", "0:" + strconv.Itoa(int(s["index"].(float64))), 49 | }...) 50 | } 51 | }() 52 | switch s["codec_type"] { 53 | case "video": 54 | /* 55 | if s["codec_name"] == "h264" { 56 | if i, _ := strconv.ParseInt(s["is_avc"], 0, 0); i != 0 { 57 | return []string{"-vcodec", "copy", "-sameq", "-vbsf", "h264_mp4toannexb"} 58 | } 59 | } 60 | */ 61 | return []string{"-target", "pal-dvd"} 62 | case "audio": 63 | if s["codec_name"] == "dca" { 64 | return []string{"-acodec", "ac3", "-ab", "224k", "-ac", "2"} 65 | } else { 66 | return []string{"-acodec", "copy"} 67 | } 68 | case "subtitle": 69 | return []string{"-scodec", "copy"} 70 | } 71 | return 72 | } 73 | 74 | // Streams the desired file in the MPEG_PS_PAL DLNA profile. 75 | func Transcode(path string, start, length time.Duration, stderr io.Writer) (r io.ReadCloser, err error) { 76 | args := []string{ 77 | "ffmpeg", 78 | "-threads", strconv.FormatInt(int64(runtime.NumCPU()), 10), 79 | "-async", "1", 80 | "-ss", FormatDurationSexagesimal(start), 81 | } 82 | if length >= 0 { 83 | args = append(args, []string{ 84 | "-t", FormatDurationSexagesimal(length), 85 | }...) 86 | } 87 | args = append(args, []string{ 88 | "-i", path, 89 | }...) 90 | info, err := ffprobe.Run(path) 91 | if err != nil { 92 | return 93 | } 94 | for _, s := range info.Streams { 95 | args = append(args, streamArgs(s)...) 96 | } 97 | args = append(args, []string{"-f", "mpegts", "pipe:"}...) 98 | return transcodePipe(args, stderr) 99 | } 100 | 101 | // Returns a stream of Chromecast supported VP8. 102 | func VP8Transcode(path string, start, length time.Duration, stderr io.Writer) (r io.ReadCloser, err error) { 103 | args := []string{ 104 | "avconv", 105 | "-threads", strconv.FormatInt(int64(runtime.NumCPU()), 10), 106 | "-async", "1", 107 | "-ss", FormatDurationSexagesimal(start), 108 | } 109 | if length > 0 { 110 | args = append(args, []string{ 111 | "-t", FormatDurationSexagesimal(length), 112 | }...) 113 | } 114 | args = append(args, []string{ 115 | "-i", path, 116 | // "-deadline", "good", 117 | // "-c:v", "libvpx", "-crf", "10", 118 | "-f", "webm", 119 | "pipe:", 120 | }...) 121 | return transcodePipe(args, stderr) 122 | } 123 | 124 | // Returns a stream of Chromecast supported matroska. 125 | func ChromecastTranscode(path string, start, length time.Duration, stderr io.Writer) (r io.ReadCloser, err error) { 126 | args := []string{ 127 | "ffmpeg", 128 | "-ss", FormatDurationSexagesimal(start), 129 | "-i", path, 130 | "-c:v", "libx264", "-preset", "ultrafast", "-profile:v", "high", "-level", "5.0", 131 | "-movflags", "+faststart+frag_keyframe+empty_moov", 132 | } 133 | if length > 0 { 134 | args = append(args, []string{ 135 | "-t", FormatDurationSexagesimal(length), 136 | }...) 137 | } 138 | args = append(args, []string{ 139 | "-f", "mp4", 140 | "pipe:", 141 | }...) 142 | return transcodePipe(args, stderr) 143 | } 144 | 145 | // Returns a stream of h264 video and mp3 audio 146 | func WebTranscode(path string, start, length time.Duration, stderr io.Writer) (r io.ReadCloser, err error) { 147 | args := []string{ 148 | "ffmpeg", 149 | "-ss", FormatDurationSexagesimal(start), 150 | "-i", path, 151 | "-pix_fmt", "yuv420p", 152 | "-c:v", "libx264", "-crf", "25", 153 | "-c:a", "mp3", "-ab", "128k", "-ar", "44100", 154 | "-preset", "ultrafast", 155 | "-movflags", "+faststart+frag_keyframe+empty_moov", 156 | } 157 | if length > 0 { 158 | args = append(args, []string{ 159 | "-t", FormatDurationSexagesimal(length), 160 | }...) 161 | } 162 | args = append(args, []string{ 163 | "-f", "mp4", 164 | "pipe:", 165 | }...) 166 | return transcodePipe(args, stderr) 167 | } 168 | 169 | // credit laurent @ https://stackoverflow.com/questions/34118732/parse-a-command-line-string-into-flags-and-arguments-in-golang 170 | func parseCommandLine(command string) ([]string, error) { 171 | var args []string 172 | state := "start" 173 | current := "" 174 | quote := "\"" 175 | escapeNext := true 176 | for i := 0; i < len(command); i++ { 177 | c := command[i] 178 | 179 | if state == "quotes" { 180 | if string(c) != quote { 181 | current += string(c) 182 | } else { 183 | args = append(args, current) 184 | current = "" 185 | state = "start" 186 | } 187 | continue 188 | } 189 | 190 | if escapeNext { 191 | current += string(c) 192 | escapeNext = false 193 | continue 194 | } 195 | 196 | if c == '\\' { 197 | escapeNext = true 198 | continue 199 | } 200 | 201 | if c == '"' || c == '\'' { 202 | state = "quotes" 203 | quote = string(c) 204 | continue 205 | } 206 | 207 | if state == "arg" { 208 | if c == ' ' || c == '\t' { 209 | args = append(args, current) 210 | current = "" 211 | state = "start" 212 | } else { 213 | current += string(c) 214 | } 215 | continue 216 | } 217 | 218 | if c != ' ' && c != '\t' { 219 | state = "arg" 220 | current += string(c) 221 | } 222 | } 223 | 224 | if state == "quotes" { 225 | return []string{}, fmt.Errorf("Unclosed quote in command line: %s", command) 226 | } 227 | 228 | if current != "" { 229 | args = append(args, current) 230 | } 231 | 232 | return args, nil 233 | } 234 | 235 | // Exec runs the cmd to generate the video to stream. It does not support seeking. Used by the dynamic stream feature. 236 | func Exec(cmds string, start, length time.Duration, stderr io.Writer) (r io.ReadCloser, err error) { 237 | cmda, aerr := parseCommandLine(cmds) 238 | if aerr != nil { 239 | err = aerr 240 | return 241 | } 242 | return transcodePipe(cmda, stderr) 243 | } 244 | -------------------------------------------------------------------------------- /upnp/eventing.go: -------------------------------------------------------------------------------- 1 | package upnp 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/xml" 6 | "fmt" 7 | "io" 8 | "net/url" 9 | "regexp" 10 | "sync" 11 | "time" 12 | 13 | "github.com/anacrolix/log" 14 | ) 15 | 16 | // TODO: Why use namespace prefixes in PropertySet et al? Because the spec 17 | // uses them, and I believe the Golang standard library XML spec implementers 18 | // incorrectly assume that you can get away with just xmlns="". 19 | 20 | // propertyset is the root element sent in an event callback. 21 | type PropertySet struct { 22 | XMLName struct{} `xml:"e:propertyset"` 23 | Properties []Property 24 | // This should be set to `"urn:schemas-upnp-org:event-1-0"`. 25 | Space string `xml:"xmlns:e,attr"` 26 | } 27 | 28 | // propertys provide namespacing to the contained variables. 29 | type Property struct { 30 | XMLName struct{} `xml:"e:property"` 31 | Variable Variable 32 | } 33 | 34 | // Represents an evented state variable that has sendEvents="yes" in its 35 | // service spec. 36 | type Variable struct { 37 | XMLName xml.Name 38 | Value string `xml:",chardata"` 39 | } 40 | 41 | type subscriber struct { 42 | sid string 43 | nextSeq uint32 // 0 for initial event, wraps from Uint32Max to 1. 44 | urls []*url.URL 45 | expiry time.Time 46 | } 47 | 48 | // Intended to eventually be an embeddable implementation for managing 49 | // eventing for a service. Not complete. 50 | type Eventing struct { 51 | mutex sync.Mutex 52 | subscribers map[string]*subscriber 53 | } 54 | 55 | func (me *Eventing) Subscribe(callback []*url.URL, timeoutSeconds int) (sid string, actualTimeout int, err error) { 56 | me.mutex.Lock() 57 | defer me.mutex.Unlock() 58 | 59 | var uuid [16]byte 60 | io.ReadFull(rand.Reader, uuid[:]) 61 | sid = FormatUUID(uuid[:]) 62 | if _, ok := me.subscribers[sid]; ok { 63 | err = fmt.Errorf("already subscribed: %s", sid) 64 | return 65 | } 66 | ssr := &subscriber{ 67 | sid: sid, 68 | urls: callback, 69 | expiry: time.Now().Add(time.Duration(timeoutSeconds) * time.Second), 70 | } 71 | if me.subscribers == nil { 72 | me.subscribers = make(map[string]*subscriber) 73 | } 74 | me.subscribers[sid] = ssr 75 | actualTimeout = int(ssr.expiry.Sub(time.Now()) / time.Second) 76 | return 77 | } 78 | 79 | func (me *Eventing) Unsubscribe(sid string) error { 80 | return nil 81 | } 82 | 83 | var callbackURLRegexp = regexp.MustCompile("<(.*?)>") 84 | 85 | // Parse the CALLBACK HTTP header in an event subscription request. See UPnP 86 | // Device Architecture 4.1.2. 87 | func ParseCallbackURLs(callback string) (ret []*url.URL) { 88 | for _, match := range callbackURLRegexp.FindAllStringSubmatch(callback, -1) { 89 | _url, err := url.Parse(match[1]) 90 | if err != nil { 91 | log.Printf("bad callback url: %q", match[1]) 92 | continue 93 | } 94 | ret = append(ret, _url) 95 | } 96 | return 97 | } 98 | -------------------------------------------------------------------------------- /upnp/eventing_test.go: -------------------------------------------------------------------------------- 1 | package upnp 2 | 3 | import ( 4 | "encoding/xml" 5 | "testing" 6 | ) 7 | 8 | // Visually verify that property sets are marshalled correctly. 9 | func TestMarshalPropertySet(t *testing.T) { 10 | b, err := xml.MarshalIndent(&PropertySet{ 11 | Properties: []Property{ 12 | { 13 | Variable: Variable{ 14 | XMLName: xml.Name{ 15 | Local: "SystemUpdateID", 16 | }, 17 | Value: "0", 18 | }, 19 | }, 20 | { 21 | Variable: Variable{ 22 | XMLName: xml.Name{ 23 | Local: "answerToTheUniverse", 24 | }, 25 | Value: "42", 26 | }, 27 | }, 28 | }, 29 | Space: "urn:schemas-upnp-org:event-1-0", 30 | }, "", " ") 31 | t.Log("\n" + string(b)) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | } 36 | 37 | func TestParseCallbackURLs(t *testing.T) { 38 | urls := ParseCallbackURLs(" ") 39 | if len(urls) != 3 { 40 | t.Fatal(len(urls)) 41 | } 42 | } 43 | 44 | func TestSubscribeRace(t *testing.T) { 45 | const n = 100 46 | 47 | e := &Eventing{} 48 | done := make(chan struct{}) 49 | 50 | doSubscribes := func() { 51 | for i := 0; i < n; i++ { 52 | e.Subscribe(nil, 10) 53 | } 54 | done <- struct{}{} 55 | } 56 | 57 | go doSubscribes() 58 | go doSubscribes() 59 | <-done 60 | <-done 61 | } 62 | -------------------------------------------------------------------------------- /upnp/upnp.go: -------------------------------------------------------------------------------- 1 | package upnp 2 | 3 | import ( 4 | "encoding/xml" 5 | "errors" 6 | "fmt" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/anacrolix/log" 12 | ) 13 | 14 | var serviceURNRegexp *regexp.Regexp = regexp.MustCompile(`^urn:(.*):service:(\w+):(\d+)$`) 15 | 16 | type ServiceURN struct { 17 | Auth string 18 | Type string 19 | Version uint64 20 | } 21 | 22 | func (me ServiceURN) String() string { 23 | return fmt.Sprintf("urn:%s:service:%s:%d", me.Auth, me.Type, me.Version) 24 | } 25 | 26 | func ParseServiceType(s string) (ret ServiceURN, err error) { 27 | matches := serviceURNRegexp.FindStringSubmatch(s) 28 | if matches == nil { 29 | err = errors.New(s) 30 | return 31 | } 32 | if len(matches) != 4 { 33 | log.Panicf("Invalid serviceURNRegexp?") 34 | } 35 | ret.Auth = matches[1] 36 | ret.Type = matches[2] 37 | ret.Version, err = strconv.ParseUint(matches[3], 0, 0) 38 | return 39 | } 40 | 41 | type SoapAction struct { 42 | ServiceURN 43 | Action string 44 | } 45 | 46 | func ParseActionHTTPHeader(s string) (ret SoapAction, err error) { 47 | if len(s) < 3 { 48 | return 49 | } 50 | if s[0] != '"' || s[len(s)-1] != '"' { 51 | return 52 | } 53 | s = s[1 : len(s)-1] 54 | hashIndex := strings.LastIndex(s, "#") 55 | if hashIndex == -1 { 56 | return 57 | } 58 | ret.Action = s[hashIndex+1:] 59 | ret.ServiceURN, err = ParseServiceType(s[:hashIndex]) 60 | return 61 | } 62 | 63 | type SpecVersion struct { 64 | Major int `xml:"major"` 65 | Minor int `xml:"minor"` 66 | } 67 | 68 | type Icon struct { 69 | Mimetype string `xml:"mimetype"` 70 | Width int `xml:"width"` 71 | Height int `xml:"height"` 72 | Depth int `xml:"depth"` 73 | URL string `xml:"url"` 74 | } 75 | 76 | type Service struct { 77 | XMLName xml.Name `xml:"service"` 78 | ServiceType string `xml:"serviceType"` 79 | ServiceId string `xml:"serviceId"` 80 | SCPDURL string 81 | ControlURL string `xml:"controlURL"` 82 | EventSubURL string `xml:"eventSubURL"` 83 | } 84 | 85 | type Device struct { 86 | DeviceType string `xml:"deviceType"` 87 | FriendlyName string `xml:"friendlyName"` 88 | Manufacturer string `xml:"manufacturer"` 89 | ModelName string `xml:"modelName"` 90 | UDN string 91 | VendorXML string `xml:",innerxml"` 92 | IconList []Icon `xml:"iconList>icon"` 93 | ServiceList []Service `xml:"serviceList>service"` 94 | PresentationURL string `xml:"presentationURL,omitempty"` 95 | } 96 | 97 | type DeviceDesc struct { 98 | XMLName xml.Name `xml:"urn:schemas-upnp-org:device-1-0 root"` 99 | NSDLNA string `xml:"xmlns:dlna,attr"` 100 | NSSEC string `xml:"xmlns:sec,attr"` 101 | SpecVersion SpecVersion `xml:"specVersion"` 102 | Device Device `xml:"device"` 103 | } 104 | 105 | type Error struct { 106 | XMLName xml.Name `xml:"urn:schemas-upnp-org:control-1-0 UPnPError"` 107 | Code uint `xml:"errorCode"` 108 | Desc string `xml:"errorDescription"` 109 | } 110 | 111 | func (e *Error) Error() string { 112 | return fmt.Sprintf("%d %s", e.Code, e.Desc) 113 | } 114 | 115 | const ( 116 | InvalidActionErrorCode = 401 117 | ActionFailedErrorCode = 501 118 | ArgumentValueInvalidErrorCode = 600 119 | ) 120 | 121 | var ( 122 | InvalidActionError = Errorf(401, "Invalid Action") 123 | ArgumentValueInvalidError = Errorf(600, "The argument value is invalid") 124 | ) 125 | 126 | // Errorf creates an UPNP error from the given code and description 127 | func Errorf(code uint, tpl string, args ...interface{}) *Error { 128 | return &Error{Code: code, Desc: fmt.Sprintf(tpl, args...)} 129 | } 130 | 131 | // ConvertError converts any error to an UPNP error 132 | func ConvertError(err error) *Error { 133 | if err == nil { 134 | return nil 135 | } 136 | if e, ok := err.(*Error); ok { 137 | return e 138 | } 139 | return Errorf(ActionFailedErrorCode, err.Error()) 140 | } 141 | 142 | type Action struct { 143 | Name string 144 | Arguments []Argument 145 | } 146 | 147 | type Argument struct { 148 | Name string 149 | Direction string 150 | RelatedStateVar string 151 | } 152 | 153 | type SCPD struct { 154 | XMLName xml.Name `xml:"urn:schemas-upnp-org:service-1-0 scpd"` 155 | SpecVersion SpecVersion `xml:"specVersion"` 156 | ActionList []Action `xml:"actionList>action"` 157 | ServiceStateTable []StateVariable `xml:"serviceStateTable>stateVariable"` 158 | } 159 | 160 | type StateVariable struct { 161 | SendEvents string `xml:"sendEvents,attr"` 162 | Name string `xml:"name"` 163 | DataType string `xml:"dataType"` 164 | AllowedValues *[]string `xml:"allowedValueList>allowedValue,omitempty"` 165 | } 166 | 167 | func FormatUUID(buf []byte) string { 168 | return fmt.Sprintf("uuid:%x-%x-%x-%x-%x", buf[:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]) 169 | } 170 | -------------------------------------------------------------------------------- /upnpav/upnpav.go: -------------------------------------------------------------------------------- 1 | package upnpav 2 | 3 | import ( 4 | "encoding/xml" 5 | "time" 6 | ) 7 | 8 | const ( 9 | // NoSuchObjectErrorCode : The specified ObjectID is invalid. 10 | NoSuchObjectErrorCode = 701 11 | ) 12 | 13 | // Resource description 14 | type Resource struct { 15 | XMLName xml.Name `xml:"res"` 16 | ProtocolInfo string `xml:"protocolInfo,attr"` 17 | URL string `xml:",chardata"` 18 | Size uint64 `xml:"size,attr,omitempty"` 19 | Bitrate uint `xml:"bitrate,attr,omitempty"` 20 | Duration string `xml:"duration,attr,omitempty"` 21 | Resolution string `xml:"resolution,attr,omitempty"` 22 | } 23 | 24 | // Container description 25 | type Container struct { 26 | Object 27 | XMLName xml.Name `xml:"container"` 28 | ChildCount int `xml:"childCount,attr"` 29 | } 30 | 31 | // Item description 32 | type Item struct { 33 | Object 34 | XMLName xml.Name `xml:"item"` 35 | Res []Resource 36 | InnerXML string `xml:",innerxml"` 37 | } 38 | 39 | // Object description 40 | type Object struct { 41 | ID string `xml:"id,attr"` 42 | ParentID string `xml:"parentID,attr"` 43 | Restricted int `xml:"restricted,attr"` // indicates whether the object is modifiable 44 | Title string `xml:"dc:title"` 45 | Class string `xml:"upnp:class"` 46 | Icon string `xml:"upnp:icon,omitempty"` 47 | Date Timestamp `xml:"dc:date"` 48 | Artist string `xml:"upnp:artist,omitempty"` 49 | Album string `xml:"upnp:album,omitempty"` 50 | Genre string `xml:"upnp:genre,omitempty"` 51 | AlbumArtURI string `xml:"upnp:albumArtURI,omitempty"` 52 | Searchable int `xml:"searchable,attr"` 53 | SearchXML string `xml:",innerxml"` 54 | } 55 | 56 | // Timestamp wraps time.Time for formatting purposes 57 | type Timestamp struct { 58 | time.Time 59 | } 60 | 61 | // MarshalXML formats the Timestamp per DIDL-Lite spec 62 | func (t Timestamp) MarshalXML(e *xml.Encoder, start xml.StartElement) error { 63 | return e.EncodeElement(t.Format("2006-01-02"), start) 64 | } 65 | --------------------------------------------------------------------------------