├── pkg
├── media
│ ├── discovery
│ │ ├── testdata
│ │ │ ├── movie.txt
│ │ │ ├── movie0.avi
│ │ │ ├── movie1.mkv
│ │ │ └── movie2.mp4
│ │ ├── media_discovery.go
│ │ ├── filesystem_test.go
│ │ └── filesystem.go
│ ├── details
│ │ ├── testdata
│ │ │ ├── omdb-not-found.json
│ │ │ └── omdb-found.json
│ │ ├── details_omdb_test.go
│ │ ├── details.go
│ │ └── details_omdb.go
│ ├── models
│ │ ├── details.go
│ │ └── media.go
│ └── translator
│ │ ├── translator_test.go
│ │ └── translator.go
├── cache
│ ├── cache.go
│ ├── memory_test.go
│ └── memory.go
├── utils
│ └── array.go
├── server
│ ├── probe.go
│ ├── logging.go
│ ├── auth
│ │ └── authenticator.go
│ ├── media_streaming.go
│ ├── server.go
│ ├── login_test.go
│ ├── login.go
│ └── media_list.go
├── config
│ └── config.go
└── files
│ └── io.go
├── .gitignore
├── res
├── ciak-media-list.png
└── ciak-media-player.png
├── ciak.go
├── docker
└── docker-compose.yml
├── .goreleaser.yml
├── .github
├── workflows
│ ├── linter.yml
│ ├── go.yml
│ ├── release.yml
│ └── docker.yml
└── dependabot.yml
├── ui
├── login.html
├── base.html
└── media-list.html
├── Dockerfile
├── go.mod
├── LICENSE
├── README.md
├── cmd
└── root.go
└── go.sum
/pkg/media/discovery/testdata/movie.txt:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pkg/media/discovery/testdata/movie0.avi:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pkg/media/discovery/testdata/movie1.mkv:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pkg/media/discovery/testdata/movie2.mp4:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | vendor/
3 | dist/
4 | /ciak
5 | /ciak.exe
6 | /ciak_daemon.db
7 |
--------------------------------------------------------------------------------
/res/ciak-media-list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GaruGaru/ciak/HEAD/res/ciak-media-list.png
--------------------------------------------------------------------------------
/res/ciak-media-player.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GaruGaru/ciak/HEAD/res/ciak-media-player.png
--------------------------------------------------------------------------------
/pkg/media/details/testdata/omdb-not-found.json:
--------------------------------------------------------------------------------
1 | {
2 | "Response": "False",
3 | "Error": "Movie not found!"
4 | }
--------------------------------------------------------------------------------
/ciak.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/GaruGaru/ciak/cmd"
5 | )
6 |
7 | func main() {
8 | cmd.Execute()
9 | }
10 |
--------------------------------------------------------------------------------
/docker/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.3"
2 | services:
3 | ciak:
4 | image: garugaru/ciak
5 | build: ../
6 | ports:
7 | - 8082:8082
8 |
--------------------------------------------------------------------------------
/pkg/cache/cache.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | type Cache interface {
4 | Get(interface{}) (interface{}, bool, error)
5 | Set(interface{}, interface{}) error
6 | }
7 |
--------------------------------------------------------------------------------
/pkg/utils/array.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | func StringIn(target string, array []string) bool {
4 | for _, item := range array {
5 | if target == item {
6 | return true
7 | }
8 | }
9 | return false
10 | }
11 |
--------------------------------------------------------------------------------
/pkg/media/discovery/media_discovery.go:
--------------------------------------------------------------------------------
1 | package discovery
2 |
3 | import (
4 | "github.com/GaruGaru/ciak/pkg/media/models"
5 | )
6 |
7 | type MediaDiscovery interface {
8 | Discover() ([]models.Media, error)
9 | Resolve(hash string) (models.Media, error)
10 | }
11 |
--------------------------------------------------------------------------------
/pkg/media/models/details.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import "time"
4 |
5 | type Details struct {
6 | Name string
7 | Director string
8 | Genre string
9 | Rating float64
10 | MaxRating float64
11 | ReleaseDate time.Time
12 | ImagePoster string
13 | }
14 |
--------------------------------------------------------------------------------
/pkg/server/probe.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/sirupsen/logrus"
5 | "net/http"
6 | )
7 |
8 | func ProbeHandler(w http.ResponseWriter, r *http.Request) {
9 | if _, err := w.Write([]byte("OK")); err != nil {
10 | logrus.Error(err.Error())
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | builds:
2 | - env:
3 | - CGO_ENABLED=0
4 | goos:
5 | - linux
6 | - darwin
7 | - windows
8 | - darwin
9 | goarch:
10 | - 386
11 | - amd64
12 | - arm
13 | - arm64
14 | goarm:
15 | - 6
16 | - 7
17 |
--------------------------------------------------------------------------------
/.github/workflows/linter.yml:
--------------------------------------------------------------------------------
1 | name: go-linter
2 | on: [push, pull_request]
3 | jobs:
4 | golangci:
5 | name: lint
6 | runs-on: ubuntu-latest
7 | steps:
8 | - uses: actions/checkout@v2
9 | - name: golangci-lint
10 | uses: golangci/golangci-lint-action@v2
11 | with:
12 | version: v1.29
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: gomod
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | time: "04:00"
8 | open-pull-requests-limit: 10
9 | ignore:
10 | - dependency-name: github.com/sirupsen/logrus
11 | versions:
12 | - 1.7.0
13 | - 1.7.1
14 | - 1.8.0
15 |
--------------------------------------------------------------------------------
/pkg/server/logging.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | log "github.com/sirupsen/logrus"
5 | "net/http"
6 | "time"
7 | )
8 |
9 | func LoggingMiddleware(next http.Handler) http.Handler {
10 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
11 |
12 | startTime := time.Now()
13 | next.ServeHTTP(w, r)
14 |
15 | log.WithFields(log.Fields{
16 | "duration": time.Since(startTime).Milliseconds(),
17 | }).Info(r.RequestURI)
18 |
19 | })
20 | }
21 |
--------------------------------------------------------------------------------
/pkg/cache/memory_test.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | import (
4 | "github.com/stretchr/testify/require"
5 | "testing"
6 | )
7 |
8 | func TestMemoryCachePut(t *testing.T) {
9 | memory := Memory()
10 |
11 | const key = "test"
12 | const expected = true
13 |
14 | err := memory.Set(key, expected)
15 | require.NoError(t, err)
16 |
17 | value, present, err := memory.Get(key)
18 | require.NoError(t, err)
19 | require.True(t, present)
20 | require.Equal(t, value, expected)
21 | }
22 |
--------------------------------------------------------------------------------
/pkg/cache/memory.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | import "sync"
4 |
5 | type InMemoryCache struct {
6 | cache *sync.Map
7 | }
8 |
9 | func Memory() *InMemoryCache {
10 | return &InMemoryCache{cache: &sync.Map{}}
11 | }
12 |
13 | func (i InMemoryCache) Get(key interface{}) (interface{}, bool, error) {
14 | value, found := i.cache.Load(key)
15 | return value, found, nil
16 | }
17 |
18 | func (i InMemoryCache) Set(key interface{}, value interface{}) error {
19 | i.cache.Store(key, value)
20 | return nil
21 | }
22 |
--------------------------------------------------------------------------------
/ui/login.html:
--------------------------------------------------------------------------------
1 | {{define "content"}}
2 |
13 | {{end}}
--------------------------------------------------------------------------------
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | name: go-build-test
2 | on: [push, pull_request]
3 | jobs:
4 | test:
5 | strategy:
6 | matrix:
7 | go-version: [1.14.x, 1.15.x]
8 | os: [ubuntu-latest, macos-latest] # windows-latest windows not supported yet
9 | runs-on: ${{ matrix.os }}
10 | steps:
11 | - name: Install Go
12 | uses: actions/setup-go@v2
13 | with:
14 | go-version: ${{ matrix.go-version }}
15 |
16 | - name: Checkout code
17 | uses: actions/checkout@v2
18 |
19 | - name: Build
20 | run: go build
21 |
22 | - name: Test
23 | run: go test ./... -race
--------------------------------------------------------------------------------
/pkg/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | type CiakConfig struct {
4 | MediaPath string
5 | ServerConfig CiakServerConfig
6 | DaemonConfig CiakDaemonConfig
7 | }
8 |
9 | type CiakServerConfig struct {
10 | ServerBinding string `json:"bind"`
11 | AuthenticationEnabled bool `json:"enable_auth"`
12 | OmdbApiKey string `json:"omdb_api_key"`
13 | }
14 |
15 | type CiakDaemonConfig struct {
16 | OutputPath string
17 | AutoConvertMedia bool
18 | DeleteOriginal bool
19 | Workers int
20 | QueueSize int
21 | Database string
22 | TransferDestination string
23 | }
24 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 | on:
3 | push:
4 | tags:
5 | - '*'
6 | jobs:
7 | goreleaser:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Checkout
11 | uses: actions/checkout@v2
12 | with:
13 | fetch-depth: 0
14 |
15 | - name: Set up Go
16 | uses: actions/setup-go@v2
17 | with:
18 | go-version: 1.15
19 |
20 | - name: Run GoReleaser
21 | uses: goreleaser/goreleaser-action@v2
22 | with:
23 | version: latest
24 | args: release --rm-dist
25 | env:
26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG GO_VERSION=1.15
2 | ARG APP_NAME="ciak"
3 | ARG PORT=8082
4 |
5 | FROM golang:${GO_VERSION}-alpine AS builder
6 |
7 | RUN apk add --no-cache ca-certificates git
8 |
9 | WORKDIR /src
10 |
11 | COPY ./go.mod ./go.sum ./
12 | RUN go mod download
13 |
14 | COPY ./ ./
15 |
16 | RUN CGO_ENABLED=0 go build \
17 | -ldflags="-s -w" \
18 | -installsuffix 'static' \
19 | -o /app .
20 |
21 | FROM linuxserver/ffmpeg
22 |
23 | WORKDIR /
24 |
25 | RUN mkdir /data && mkdir /transfer
26 |
27 | VOLUME /transfer
28 |
29 | VOLUME /data
30 |
31 | VOLUME /db
32 |
33 | COPY ui/ /ui
34 |
35 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
36 |
37 | COPY --from=builder /app /app
38 |
39 | EXPOSE ${PORT}
40 |
41 | ENTRYPOINT ["/app"]
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/GaruGaru/ciak
2 |
3 | go 1.15
4 |
5 | require (
6 | github.com/GaruGaru/duty v0.0.0-20190213134635-eef338d3b083
7 | github.com/fsnotify/fsnotify v1.4.7
8 | github.com/gorilla/mux v1.8.0
9 | github.com/gorilla/sessions v1.2.1
10 | github.com/inconshreveable/mousetrap v1.0.0 // indirect
11 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
12 | github.com/sirupsen/logrus v1.3.0
13 | github.com/spf13/cobra v0.0.3
14 | github.com/spf13/pflag v1.0.3 // indirect
15 | github.com/stretchr/testify v1.7.0
16 | golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67 // indirect
17 | golang.org/x/sys v0.0.0-20190213121743-983097b1a8a3 // indirect
18 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
19 | )
20 |
--------------------------------------------------------------------------------
/pkg/media/translator/translator_test.go:
--------------------------------------------------------------------------------
1 | package translator
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestDecoderNameWithTorrentNotation(t *testing.T) {
8 |
9 | input := "Godzilla.2014.720p.BluRay.x264-LEONARDO_[scarabey.org].mkv"
10 |
11 | output := Translate(input)
12 |
13 | expectedOutput := "Godzilla"
14 |
15 | if output != expectedOutput {
16 | t.Fatalf("expected %s as decoded output but got %s", expectedOutput, output)
17 | }
18 |
19 | }
20 |
21 | func TestDecoderSimpleName(t *testing.T) {
22 |
23 | input := "Godzilla 2014"
24 |
25 | output := Translate(input)
26 |
27 | expectedOutput := "Godzilla"
28 |
29 | if output != expectedOutput {
30 | t.Fatalf("expected %s as decoded output but got %s", expectedOutput, output)
31 | }
32 |
33 | }
34 |
--------------------------------------------------------------------------------
/pkg/media/translator/translator.go:
--------------------------------------------------------------------------------
1 | package translator
2 |
3 | import (
4 | "regexp"
5 | "strings"
6 | )
7 |
8 | var removeStrings = []string{
9 | "720p", "1080p", "x264", "BluRay", "mkv", "avi", "mp4",
10 | ")", "(",
11 | }
12 |
13 | var nameRegex = regexp.MustCompile(`(.*?)(\d\d\d\d|S\d\dE\d\d)`)
14 |
15 | var removeLogoRegex = regexp.MustCompile(`(\[.*?])`)
16 |
17 | func Translate(name string) string {
18 |
19 | name = strings.Replace(name, ".", " ", -1)
20 |
21 | name = removeLogoRegex.ReplaceAllString(name, "")
22 |
23 | for _, rmv := range removeStrings {
24 | name = strings.Replace(name, rmv, "", -1)
25 | }
26 |
27 | allString := nameRegex.FindStringSubmatch(name)
28 |
29 | if len(allString) == 0 {
30 | return strings.TrimSpace(name)
31 | }
32 |
33 | return strings.TrimSpace(allString[1])
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/pkg/media/discovery/filesystem_test.go:
--------------------------------------------------------------------------------
1 | package discovery
2 |
3 | import (
4 | "github.com/GaruGaru/ciak/pkg/media/models"
5 | "github.com/stretchr/testify/require"
6 | "path"
7 | "testing"
8 | )
9 |
10 | func TestFileSystemDiscovery(t *testing.T) {
11 | discover := NewFileSystemDiscovery("testdata")
12 |
13 | medias, err := discover.Discover()
14 | require.NoError(t, err)
15 | require.Len(t, medias, 3)
16 |
17 | for _, media := range medias {
18 | require.Contains(t, media.FilePath, "testdata")
19 | }
20 |
21 | require.Equal(t, medias[0], models.Media{
22 | Name: "movie0",
23 | Format: models.MediaFormatAvi,
24 | FilePath: path.Join("testdata", "movie0.avi"),
25 | Size: 0,
26 | })
27 |
28 | require.Equal(t, medias[1], models.Media{
29 | Name: "movie1",
30 | Format: models.MediaFormatMkv,
31 | FilePath: path.Join("testdata", "movie1.mkv"),
32 | Size: 0,
33 | })
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 Tommaso Garuglieri
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
--------------------------------------------------------------------------------
/ui/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Ciak media list
6 |
8 |
9 |
10 |
19 |
20 |
21 | {{block "content" .}}No content provided{{end}}
22 |
23 |
24 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/pkg/server/auth/authenticator.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | )
7 |
8 | type User struct {
9 | Name string
10 | }
11 |
12 | type Authenticator interface {
13 | Authenticate(username string, password string) (User, error)
14 | }
15 |
16 | type NoOpAuthenticator struct{}
17 |
18 | func (a NoOpAuthenticator) Authenticate(username string, password string) (User, error) {
19 | return User{Name: username}, nil
20 | }
21 |
22 | type StaticCredentialsAuthenticator struct {
23 | username string
24 | password string
25 | }
26 |
27 | func NewStaticCredentialsApi(username string, password string) StaticCredentialsAuthenticator {
28 | return StaticCredentialsAuthenticator{
29 | username: username,
30 | password: password,
31 | }
32 | }
33 |
34 | func (a StaticCredentialsAuthenticator) Authenticate(username string, password string) (User, error) {
35 | if username == a.username && password == a.password {
36 | return User{Name: username}, nil
37 | }
38 | return User{}, fmt.Errorf("login error")
39 | }
40 |
41 | func NewEnvAuthenticator() StaticCredentialsAuthenticator {
42 | envUser := os.Getenv("CIAK_USERNAME")
43 | envPassword := os.Getenv("CIAK_PASSWORD")
44 | return NewStaticCredentialsApi(envUser, envPassword)
45 | }
46 |
--------------------------------------------------------------------------------
/pkg/files/io.go:
--------------------------------------------------------------------------------
1 | package files
2 |
3 | import (
4 | "io"
5 | "io/ioutil"
6 | "os"
7 | "path"
8 | )
9 |
10 | func CopyDirectory(src string, dst string) error {
11 | var err error
12 | var fds []os.FileInfo
13 | var srcinfo os.FileInfo
14 |
15 | if srcinfo, err = os.Stat(src); err != nil {
16 | return err
17 | }
18 |
19 | if err = os.MkdirAll(dst, srcinfo.Mode()); err != nil {
20 | return err
21 | }
22 |
23 | if fds, err = ioutil.ReadDir(src); err != nil {
24 | return err
25 | }
26 | for _, fd := range fds {
27 | srcfp := path.Join(src, fd.Name())
28 | dstfp := path.Join(dst, fd.Name())
29 |
30 | if fd.IsDir() {
31 | if err = CopyDirectory(srcfp, dstfp); err != nil {
32 | return err
33 | }
34 | } else {
35 | if err = CopyFile(srcfp, dstfp); err != nil {
36 | return err
37 | }
38 | }
39 | }
40 | return nil
41 | }
42 |
43 | func CopyFile(src, dst string) error {
44 | var err error
45 | var srcfd *os.File
46 | var dstfd *os.File
47 | var srcinfo os.FileInfo
48 |
49 | if srcfd, err = os.Open(src); err != nil {
50 | return err
51 | }
52 | defer srcfd.Close()
53 |
54 | if dstfd, err = os.Create(dst); err != nil {
55 | return err
56 | }
57 | defer dstfd.Close()
58 |
59 | if _, err = io.Copy(dstfd, srcfd); err != nil {
60 | return err
61 | }
62 | if srcinfo, err = os.Stat(src); err != nil {
63 | return err
64 | }
65 | return os.Chmod(dst, srcinfo.Mode())
66 | }
67 |
--------------------------------------------------------------------------------
/ui/media-list.html:
--------------------------------------------------------------------------------
1 | {{define "content"}}
2 |
3 |
4 | Your media library
5 |
6 |
7 |
8 |
9 | {{if .NoMediasFound}}
10 |
No playable media found :(
11 | {{end}}
12 |
13 | {{range .PageMedia}}
14 |
15 |
44 | {{end}}
45 |
46 |
47 |
48 |
49 | {{end}}
--------------------------------------------------------------------------------
/pkg/media/details/testdata/omdb-found.json:
--------------------------------------------------------------------------------
1 | {
2 | "Title": "The Shining",
3 | "Year": "1980",
4 | "Rated": "R",
5 | "Released": "13 Jun 1980",
6 | "Runtime": "146 min",
7 | "Genre": "Drama, Horror",
8 | "Director": "Stanley Kubrick",
9 | "Writer": "Stephen King (based upon the novel by), Stanley Kubrick (screenplay by), Diane Johnson (screenplay by)",
10 | "Actors": "Jack Nicholson, Shelley Duvall, Danny Lloyd, Scatman Crothers",
11 | "Plot": "A family heads to an isolated hotel for the winter where a sinister presence influences the father into violence, while his psychic son sees horrific forebodings from both past and future.",
12 | "Language": "English",
13 | "Country": "UK, USA",
14 | "Awards": "4 wins & 8 nominations.",
15 | "Poster": "https://m.media-amazon.com/images/M/MV5BZWFlYmY2MGEtZjVkYS00YzU4LTg0YjQtYzY1ZGE3NTA5NGQxXkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_SX300.jpg",
16 | "Ratings": [
17 | {
18 | "Source": "Internet Movie Database",
19 | "Value": "8.4/10"
20 | },
21 | {
22 | "Source": "Rotten Tomatoes",
23 | "Value": "84%"
24 | },
25 | {
26 | "Source": "Metacritic",
27 | "Value": "66/100"
28 | }
29 | ],
30 | "Metascore": "66",
31 | "imdbRating": "8.4",
32 | "imdbVotes": "886,080",
33 | "imdbID": "tt0081505",
34 | "Type": "movie",
35 | "DVD": "N/A",
36 | "BoxOffice": "N/A",
37 | "Production": "Producers Circle, Warner Brothers, Peregrine, Hawk Films",
38 | "Website": "N/A",
39 | "Response": "True"
40 | }
--------------------------------------------------------------------------------
/pkg/server/media_streaming.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "fmt"
5 | "github.com/GaruGaru/ciak/pkg/media/models"
6 | "github.com/gorilla/mux"
7 | "github.com/sirupsen/logrus"
8 | "net/http"
9 | "path/filepath"
10 | )
11 |
12 | var playableMediaFormats = []models.MediaFormat{
13 | models.MediaFormatFlac,
14 | models.MediaFormatMp4,
15 | models.MediaFormatMp4a,
16 | models.MediaFormatMp3,
17 | models.MediaFormatOgv,
18 | models.MediaFormatOgm,
19 | models.MediaFormatOgg,
20 | models.MediaFormatOga,
21 | models.MediaFormatOpus,
22 | models.MediaFormatWebm,
23 | models.MediaFormatWav,
24 | }
25 |
26 | func (s CiakServer) MediaStreamingHandler(w http.ResponseWriter, r *http.Request) {
27 | params := mux.Vars(r)
28 |
29 | media, err := s.MediaDiscovery.Resolve(params["media"])
30 |
31 | if err != nil {
32 | if _, err := w.Write([]byte(err.Error())); err != nil {
33 | logrus.Error(err.Error())
34 | }
35 | return
36 | }
37 |
38 | if isExtensionPlayable(media.Format) {
39 | w.Header().Set("Accept-Ranges", "bytes")
40 | w.Header().Set("Content-Type", "video/"+media.Format.Name())
41 | } else {
42 | _, fileName := filepath.Split(media.FilePath)
43 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
44 | }
45 |
46 | http.ServeFile(w, r, media.FilePath)
47 | }
48 |
49 | func isExtensionPlayable(format models.MediaFormat) bool {
50 | for _, playableFormat := range playableMediaFormats {
51 | if playableFormat == format {
52 | return true
53 | }
54 | }
55 | return false
56 | }
57 |
--------------------------------------------------------------------------------
/pkg/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/GaruGaru/ciak/pkg/config"
5 | "github.com/GaruGaru/ciak/pkg/media/details"
6 | "github.com/GaruGaru/ciak/pkg/media/discovery"
7 | "github.com/GaruGaru/ciak/pkg/server/auth"
8 | "github.com/gorilla/mux"
9 | log "github.com/sirupsen/logrus"
10 | "net/http"
11 | )
12 |
13 | const (
14 | serverVersion = "0.0.2"
15 | )
16 |
17 | type CiakServer struct {
18 | Config config.CiakServerConfig
19 | MediaDiscovery discovery.MediaDiscovery
20 | Authenticator auth.Authenticator
21 | DetailsRetriever *details.Controller
22 | }
23 |
24 | func NewCiakServer(
25 | conf config.CiakServerConfig,
26 | discovery discovery.MediaDiscovery,
27 | authenticator auth.Authenticator,
28 | DetailsRetriever *details.Controller,
29 | ) CiakServer {
30 | return CiakServer{
31 | Config: conf,
32 | MediaDiscovery: discovery,
33 | Authenticator: authenticator,
34 | DetailsRetriever: DetailsRetriever,
35 | }
36 | }
37 |
38 | func (s CiakServer) Run() error {
39 | log.WithFields(log.Fields{
40 | "bind": s.Config.ServerBinding,
41 | "version": serverVersion,
42 | }).Info("Ciak server started")
43 |
44 | router := mux.NewRouter()
45 | s.initRouting(router)
46 | return http.ListenAndServe(s.Config.ServerBinding, router)
47 | }
48 |
49 | func (s CiakServer) initRouting(router *mux.Router) {
50 | router.HandleFunc("/probe", ProbeHandler)
51 | router.HandleFunc("/", s.MediaListHandler)
52 | router.HandleFunc("/media/{media}", s.MediaStreamingHandler)
53 | router.HandleFunc("/login", s.LoginPageHandler)
54 | router.HandleFunc("/api/login", s.LoginApiHandler)
55 | router.Use(LoggingMiddleware)
56 | router.Use(s.SessionAuthMiddleware)
57 | }
58 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Ciak is a lightweight media server written in go
2 |
3 | [](https://goreportcard.com/report/github.com/GaruGaru/ciak)
4 | 
5 | 
6 | 
7 | 
8 | 
9 |
10 |
11 | Ciak allows you to show and stream your personal media tv series, movies, etc with a simple and clean web ui.
12 | The server also provide on the fly video encoding in order to stream non standard formats such as avi, mkv...
13 |
14 |
15 |
16 |
17 | ## Run ciak
18 |
19 | ### Using go
20 |
21 | Install ciak
22 |
23 |
24 | go get -u github.com/garugaru/ciak
25 |
26 |
27 | Launch the media server (on 0.0.0.0:8082)
28 |
29 |
30 | ciak --media=
31 |
32 |
33 |
34 | ### Using docker
35 |
36 |
37 | docker run -v :/data -p 8082:8082 garugaru/ciak
38 |
39 |
40 |
41 | ### Configuration
42 |
43 | You can configure Ciak using the command line flags
44 |
45 |
46 | * **--bind** binding for the webserver interface:port (default 0.0.0.0:8082)
47 |
48 | * **--media** media files directory (default /data)
49 |
50 | * **--auth** enable web server authentication (default false) the authentication is configured by the env variables **CIAK_USERNAME** and **CIAK_PASSWORD**
51 |
52 | * **--omdb-api-key** omdbapi.com api key used for movie metadata retrieving
53 |
54 | * **--db** database file path (default /ciak_daemon.db)
--------------------------------------------------------------------------------
/pkg/media/models/media.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "github.com/sirupsen/logrus"
7 | "hash/fnv"
8 | )
9 |
10 | var (
11 | ErrMediaFormatNotSupported = errors.New("media format not supported")
12 | )
13 |
14 | type MediaFormat int
15 |
16 | const (
17 | MediaFormatAvi MediaFormat = iota
18 | MediaFormatMkv
19 | MediaFormatFlac
20 | MediaFormatMp4
21 | MediaFormatMp4a
22 | MediaFormatMp3
23 | MediaFormatOgv
24 | MediaFormatOgm
25 | MediaFormatOgg
26 | MediaFormatOga
27 | MediaFormatOpus
28 | MediaFormatWebm
29 | MediaFormatWav
30 | )
31 |
32 | var SupportedMediaFormat = []MediaFormat{
33 | MediaFormatAvi,
34 | MediaFormatMkv,
35 | MediaFormatFlac,
36 | MediaFormatMp4,
37 | MediaFormatMp4a,
38 | MediaFormatMp3,
39 | MediaFormatOgv,
40 | MediaFormatOgm,
41 | MediaFormatOgg,
42 | MediaFormatOga,
43 | MediaFormatOpus,
44 | MediaFormatWebm,
45 | MediaFormatWav,
46 | }
47 |
48 | func MediaFormatFrom(raw string) (MediaFormat, error) {
49 | for _, format := range SupportedMediaFormat {
50 | if format.Name() == raw || format.Extension() == raw {
51 | return format, nil
52 | }
53 | }
54 |
55 | return MediaFormat(-1), ErrMediaFormatNotSupported
56 | }
57 |
58 | func (d MediaFormat) Extension() string {
59 | return fmt.Sprintf(".%s", d.Name())
60 | }
61 |
62 | func (d MediaFormat) Name() string {
63 | return [...]string{
64 | "avi", "mkv", "flac", "mp4", "m4a", "mp3", "ogv",
65 | "ogm", "ogg", "oga", "opus", "webm", "wav",
66 | }[d]
67 | }
68 |
69 | type Media struct {
70 | Name string
71 | Format MediaFormat
72 | FilePath string
73 | Size int64
74 | }
75 |
76 | func (m Media) Hash() string {
77 | h := fnv.New32a()
78 | _, err := h.Write([]byte(fmt.Sprintf("%s%s", m.FilePath, m.Name)))
79 | if err != nil {
80 | logrus.Error()
81 | }
82 | return fmt.Sprint(h.Sum32())
83 | }
84 |
--------------------------------------------------------------------------------
/pkg/server/login_test.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/GaruGaru/ciak/pkg/cache"
5 | "github.com/GaruGaru/ciak/pkg/config"
6 | "github.com/GaruGaru/ciak/pkg/media/details"
7 | "github.com/GaruGaru/ciak/pkg/server/auth"
8 | "github.com/stretchr/testify/require"
9 | "net/http"
10 | "net/http/httptest"
11 | "net/url"
12 | "strings"
13 | "testing"
14 | )
15 |
16 | func TestLoginApiSuccess(t *testing.T) {
17 | const password = "test_password"
18 | const username = "test_username"
19 |
20 | srv := NewCiakServer(
21 | config.CiakServerConfig{AuthenticationEnabled: false},
22 | nil,
23 | auth.NewStaticCredentialsApi(username, password),
24 | details.NewController(cache.Memory()),
25 | )
26 |
27 | form := url.Values{}
28 | form.Add("username", username)
29 | form.Add("password", password)
30 |
31 | req := httptest.NewRequest("POST", "/login", strings.NewReader(form.Encode()))
32 | req.PostForm = form
33 |
34 | resp := httptest.NewRecorder()
35 |
36 | srv.LoginApiHandler(resp, req)
37 |
38 | require.Equal(t, http.StatusFound, resp.Code)
39 | val, present := resp.Header()["Set-Cookie"]
40 | require.True(t, present)
41 | require.NotEmpty(t, val)
42 | }
43 |
44 | func TestLoginApiFail(t *testing.T) {
45 | const password = "test_password"
46 | const username = "test_username"
47 |
48 | srv := NewCiakServer(
49 | config.CiakServerConfig{AuthenticationEnabled: false},
50 | nil,
51 | auth.NewStaticCredentialsApi(username, password),
52 | details.NewController(cache.Memory()),
53 | )
54 |
55 | form := url.Values{}
56 | form.Add("username", username)
57 | form.Add("password", "incorrect"+password)
58 |
59 | req := httptest.NewRequest("POST", "/login", strings.NewReader(form.Encode()))
60 | req.PostForm = form
61 |
62 | resp := httptest.NewRecorder()
63 |
64 | srv.LoginApiHandler(resp, req)
65 |
66 | require.Equal(t, http.StatusFound, resp.Code)
67 | _, present := resp.Header()["Set-Cookie"]
68 | require.False(t, present)
69 | }
70 |
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 | "github.com/GaruGaru/ciak/pkg/cache"
6 | "github.com/GaruGaru/ciak/pkg/config"
7 | "github.com/GaruGaru/ciak/pkg/media/details"
8 | "github.com/GaruGaru/ciak/pkg/media/discovery"
9 | "github.com/GaruGaru/ciak/pkg/server"
10 | "github.com/GaruGaru/ciak/pkg/server/auth"
11 | "github.com/spf13/cobra"
12 | "os"
13 | )
14 |
15 | var (
16 | conf config.CiakConfig
17 | )
18 |
19 | var rootCmd = &cobra.Command{
20 | Use: "ciak",
21 | Short: "Ciak is a lightweight media server.",
22 | Run: func(cmd *cobra.Command, args []string) {
23 |
24 | conf.DaemonConfig.OutputPath = conf.MediaPath
25 |
26 | mediaDiscovery := discovery.NewFileSystemDiscovery(conf.MediaPath)
27 |
28 | authenticator := auth.NewEnvAuthenticator()
29 |
30 | detailsRetrievers := make([]details.Retriever, 0)
31 | if conf.ServerConfig.OmdbApiKey != "" {
32 | detailsRetrievers = append(detailsRetrievers, details.Omdb(conf.ServerConfig.OmdbApiKey))
33 | }
34 |
35 | detailsController := details.NewController(cache.Memory(), detailsRetrievers...)
36 |
37 | server := server.NewCiakServer(conf.ServerConfig, mediaDiscovery, authenticator, detailsController)
38 |
39 | err := server.Run()
40 |
41 | if err != nil {
42 | panic(err)
43 | }
44 |
45 | },
46 | }
47 |
48 | func init() {
49 | rootCmd.PersistentFlags().StringVar(&conf.MediaPath, "media", "/data", "Path containing media files")
50 | rootCmd.PersistentFlags().StringVar(&conf.ServerConfig.ServerBinding, "bind", "0.0.0.0:8082", "interface and port binding for the web server")
51 | rootCmd.PersistentFlags().BoolVar(&conf.ServerConfig.AuthenticationEnabled, "auth", false, "if active enable user authentication for the web server")
52 | rootCmd.PersistentFlags().StringVar(&conf.DaemonConfig.Database, "db", "ciak_daemon.db", "database file used for persistence")
53 | rootCmd.PersistentFlags().StringVar(&conf.ServerConfig.OmdbApiKey, "omdb-api-key", "", "omdb movie metadata api key")
54 |
55 | }
56 |
57 | func Execute() {
58 | if err := rootCmd.Execute(); err != nil {
59 | fmt.Println(err)
60 | os.Exit(1)
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/pkg/media/details/details_omdb_test.go:
--------------------------------------------------------------------------------
1 | package details
2 |
3 | import (
4 | "github.com/stretchr/testify/require"
5 | "io/ioutil"
6 | "net/http"
7 | "net/http/httptest"
8 | "path"
9 | "testing"
10 | "time"
11 | )
12 |
13 | func TestRetrieveDataFromOmdb(t *testing.T) {
14 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15 | if r.Method != http.MethodGet {
16 | w.WriteHeader(http.StatusMethodNotAllowed)
17 | return
18 | }
19 |
20 | apikey := r.URL.Query()["apikey"]
21 | if len(apikey) == 0 || apikey[0] != "test" {
22 | w.WriteHeader(http.StatusBadRequest)
23 | return
24 | }
25 |
26 | queries := r.URL.Query()["t"]
27 | if len(queries) == 0 {
28 | w.WriteHeader(http.StatusBadRequest)
29 | return
30 | }
31 |
32 | query := queries[0]
33 | if query == "found" {
34 | response, err := ioutil.ReadFile(path.Join("testdata/omdb-found.json"))
35 | require.NoError(t, err)
36 | _, err = w.Write(response)
37 | require.NoError(t, err)
38 | } else {
39 | response, err := ioutil.ReadFile(path.Join("testdata/omdb-not-found.json"))
40 | require.NoError(t, err)
41 | _, err = w.Write(response)
42 | require.NoError(t, err)
43 | }
44 |
45 | }))
46 | defer srv.Close()
47 |
48 | omdbClient := &OmdbClient{
49 | endpoint: srv.URL,
50 | apiKey: "test",
51 | httpClient: &http.Client{
52 | Timeout: 10 * time.Second,
53 | },
54 | }
55 |
56 | details, err := omdbClient.Details(Request{
57 | Title: "found",
58 | })
59 |
60 | require.NoError(t, err)
61 | require.Equal(t, details.Name, "found")
62 | require.Equal(t, details.Director, "Stanley Kubrick")
63 | require.Equal(t, details.ImagePoster, "https://m.media-amazon.com/images/M/MV5BZWFlYmY2MGEtZjVkYS00YzU4LTg0YjQtYzY1ZGE3NTA5NGQxXkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_SX300.jpg")
64 | require.NotEqual(t, details.ReleaseDate, time.Time{})
65 | require.Equal(t, details.Genre, "Drama, Horror")
66 |
67 | require.Equal(t, details.MaxRating, 100.)
68 | require.Equal(t, details.Rating, 84.)
69 |
70 | _, err = omdbClient.Details(Request{
71 | Title: "notfound",
72 | })
73 | require.Error(t, err)
74 |
75 | }
76 |
--------------------------------------------------------------------------------
/pkg/media/details/details.go:
--------------------------------------------------------------------------------
1 | package details
2 |
3 | import (
4 | "errors"
5 | "github.com/GaruGaru/ciak/pkg/cache"
6 | "github.com/GaruGaru/ciak/pkg/media/models"
7 | "github.com/sirupsen/logrus"
8 | "sync"
9 | )
10 |
11 | var (
12 | ErrDetailsNotFound = errors.New("media details not found")
13 | )
14 |
15 | type Request struct {
16 | Title string
17 | }
18 |
19 | type Retriever interface {
20 | Details(Request) (models.Details, error)
21 | }
22 |
23 | type Controller struct {
24 | Retrievers []Retriever
25 | Cache cache.Cache
26 | }
27 |
28 | func NewController(cache cache.Cache, retrievers ...Retriever) *Controller {
29 | return &Controller{
30 | Retrievers: retrievers,
31 | Cache: cache,
32 | }
33 | }
34 |
35 | func (c *Controller) Details(request Request) (models.Details, error) {
36 | cached, present, err := c.Cache.Get(request)
37 | if err != nil {
38 | logrus.Warnf("error reading from cache: %s", err)
39 | }
40 |
41 | if present {
42 | return cached.(models.Details), nil
43 | }
44 |
45 | for _, retriever := range c.Retrievers {
46 | details, err := retriever.Details(request)
47 | if err != nil {
48 | // todo we may want to log this
49 | continue
50 | }
51 |
52 | if err := c.Cache.Set(request, details); err != nil {
53 | logrus.Warnf("error writing from cache: %s", err)
54 | }
55 | return details, nil
56 | }
57 |
58 | return models.Details{}, ErrDetailsNotFound
59 | }
60 |
61 | func (c *Controller) DetailsByTitleBulk(requests ...Request) (map[string]models.Details, error) {
62 | var wg sync.WaitGroup
63 | wg.Add(len(requests))
64 |
65 | results := make(chan models.Details, len(requests))
66 | for _, request := range requests {
67 | go func(request Request) {
68 | defer wg.Done()
69 | details, err := c.Details(request)
70 | if err != nil {
71 | logrus.Debugf("unable to get title metadata for %s: %s", request.Title, err.Error())
72 | }
73 | results <- details
74 | }(request)
75 | }
76 |
77 | wg.Wait()
78 |
79 | close(results)
80 |
81 | out := make(map[string]models.Details)
82 |
83 | for res := range results {
84 | out[res.Name] = res
85 | }
86 |
87 | return out, nil
88 | }
89 |
--------------------------------------------------------------------------------
/pkg/server/login.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "fmt"
5 | "github.com/GaruGaru/ciak/pkg/server/auth"
6 | "github.com/GaruGaru/ciak/pkg/utils"
7 | "github.com/gorilla/sessions"
8 | "github.com/sirupsen/logrus"
9 | "html/template"
10 | "net/http"
11 | )
12 |
13 | var UnauthenticatedUrls = []string{
14 | "/login",
15 | "/probe",
16 | "/api/login",
17 | }
18 |
19 | type LoginPage struct {
20 | Title string
21 | }
22 |
23 | var store = sessions.NewCookieStore([]byte("ciak_session"))
24 |
25 | func (s CiakServer) LoginApiHandler(w http.ResponseWriter, r *http.Request) {
26 |
27 | username := r.FormValue("username")
28 | password := r.FormValue("password")
29 |
30 | authUser, err := s.Authenticator.Authenticate(username, password)
31 |
32 | if err == nil {
33 | if err := s.createSession(w, r, authUser); err != nil {
34 | http.Redirect(w, r, "/login", http.StatusFound)
35 | return
36 | }
37 | http.Redirect(w, r, "/", http.StatusFound)
38 | } else {
39 | http.Redirect(w, r, "/login", http.StatusFound)
40 | }
41 |
42 | }
43 |
44 | func (s CiakServer) createSession(w http.ResponseWriter, r *http.Request, user auth.User) error {
45 | session, err := store.Get(r, "user")
46 |
47 | if err != nil {
48 | return err
49 | }
50 |
51 | session.Values["username"] = user.Name
52 | return store.Save(r, w, session)
53 | }
54 |
55 | func (s CiakServer) LoginPageHandler(w http.ResponseWriter, r *http.Request) {
56 | err := template.Must(template.ParseFiles("ui/base.html", "ui/login.html")).Execute(w, LoginPage{
57 | Title: "Login",
58 | })
59 |
60 | if err != nil {
61 | logrus.Error(err.Error())
62 | }
63 | }
64 |
65 | func (s CiakServer) SessionAuthMiddleware(next http.Handler) http.Handler {
66 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67 |
68 | if !s.Config.AuthenticationEnabled || utils.StringIn(r.URL.Path, UnauthenticatedUrls) {
69 | next.ServeHTTP(w, r)
70 | return
71 | }
72 |
73 | session, err := store.Get(r, "user")
74 |
75 | if err != nil {
76 | fmt.Println("Session error ", err)
77 | return
78 | }
79 |
80 | if !session.IsNew {
81 | next.ServeHTTP(w, r)
82 | } else {
83 | http.Redirect(w, r, "/login", http.StatusFound)
84 | }
85 |
86 | })
87 | }
88 |
--------------------------------------------------------------------------------
/pkg/media/discovery/filesystem.go:
--------------------------------------------------------------------------------
1 | package discovery
2 |
3 | import (
4 | "fmt"
5 | "github.com/GaruGaru/ciak/pkg/media/models"
6 | "github.com/GaruGaru/ciak/pkg/media/translator"
7 | log "github.com/sirupsen/logrus"
8 | "os"
9 | "path"
10 | "path/filepath"
11 | "sort"
12 | "strings"
13 | )
14 |
15 | type FileSystemMediaDiscovery struct {
16 | BasePath string
17 | }
18 |
19 | func NewFileSystemDiscovery(basePath string) FileSystemMediaDiscovery {
20 | return FileSystemMediaDiscovery{BasePath: basePath}
21 | }
22 |
23 | func (d FileSystemMediaDiscovery) Resolve(hash string) (models.Media, error) {
24 | mediaList, err := d.Discover()
25 |
26 | if err != nil {
27 | return models.Media{}, nil
28 | }
29 |
30 | for _, m := range mediaList {
31 | if m.Hash() == hash {
32 | return m, nil
33 | }
34 | }
35 |
36 | return models.Media{}, fmt.Errorf("no media found with Hash %s", hash)
37 | }
38 |
39 | func (d FileSystemMediaDiscovery) Discover() ([]models.Media, error) {
40 |
41 | mediaList := make([]models.Media, 0)
42 |
43 | err := filepath.Walk(d.BasePath, func(filePath string, file os.FileInfo, err error) error {
44 |
45 | if err != nil {
46 | return err
47 | }
48 |
49 | if file.IsDir() {
50 | return nil
51 | }
52 |
53 | media, err := fileToMedia(file, filePath)
54 | if err == models.ErrMediaFormatNotSupported {
55 | return nil
56 | }
57 |
58 | if err != nil {
59 | return err
60 | }
61 |
62 | mediaList = append(mediaList, media)
63 |
64 | return nil
65 | })
66 |
67 | if err != nil {
68 | return nil, err
69 | }
70 |
71 | log.Info("Found ", len(mediaList), " media after discovery")
72 |
73 | sort.Slice(mediaList, func(i, j int) bool {
74 | return mediaList[i].Name < mediaList[j].Name
75 | })
76 | return mediaList, nil
77 | }
78 |
79 | func fileToMedia(fileInfo os.FileInfo, filePath string) (models.Media, error) {
80 | extension := path.Ext(filePath)
81 | mediaExt, err := models.MediaFormatFrom(extension)
82 | if err != nil {
83 | return models.Media{}, err
84 | }
85 |
86 | name := strings.Replace(fileInfo.Name(), extension, "", 1)
87 | return models.Media{
88 | Name: translator.Translate(name),
89 | FilePath: filePath,
90 | Size: fileInfo.Size() / 1024 / 1024,
91 | Format: mediaExt,
92 | }, nil
93 | }
94 |
--------------------------------------------------------------------------------
/pkg/server/media_list.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "github.com/GaruGaru/ciak/pkg/media/details"
5 | "github.com/GaruGaru/ciak/pkg/media/models"
6 | "github.com/sirupsen/logrus"
7 | "html/template"
8 | "net/http"
9 | )
10 |
11 | type PageMediaRating struct {
12 | Value float64
13 | Max float64
14 | Present bool
15 | }
16 |
17 | type PageMedia struct {
18 | Media models.Media
19 | Cover string
20 | Playable bool
21 | Rating PageMediaRating
22 | }
23 |
24 | type MediaListPage struct {
25 | Title string
26 | MediaCount int
27 | PageMedia []PageMedia
28 | NoMediasFound bool
29 | }
30 |
31 | func mediaToTitlesList(media []models.Media) []details.Request {
32 | titles := make([]details.Request, 0)
33 | for _, item := range media {
34 | titles = append(titles, details.Request{
35 | Title: item.Name,
36 | })
37 | }
38 | return titles
39 | }
40 |
41 | func (s CiakServer) MediaListHandler(w http.ResponseWriter, r *http.Request) {
42 | mediaList, err := s.MediaDiscovery.Discover()
43 | if err != nil {
44 | w.WriteHeader(http.StatusInternalServerError)
45 | _, _ = w.Write([]byte(err.Error()))
46 | return
47 | }
48 |
49 | mediaMetadata, err := s.DetailsRetriever.DetailsByTitleBulk(mediaToTitlesList(mediaList)...)
50 |
51 | if err != nil {
52 | w.WriteHeader(http.StatusInternalServerError)
53 | _, _ = w.Write([]byte(err.Error()))
54 | return
55 | }
56 |
57 | pageMediaList := make([]PageMedia, 0)
58 |
59 | for _, media := range mediaList {
60 |
61 | metadata := mediaMetadata[media.Name]
62 |
63 | if metadata.ImagePoster == "" {
64 | metadata.ImagePoster = "https://via.placeholder.com/300"
65 | }
66 |
67 | pageMediaList = append(pageMediaList, PageMedia{
68 | Media: media,
69 | Cover: metadata.ImagePoster,
70 | Playable: isExtensionPlayable(media.Format),
71 | Rating: PageMediaRating{
72 | Value: metadata.Rating,
73 | Max: metadata.MaxRating,
74 | Present: metadata.MaxRating != 0,
75 | },
76 | })
77 |
78 | }
79 |
80 | mediaListPage := MediaListPage{
81 | Title: "Home",
82 | MediaCount: len(pageMediaList),
83 | PageMedia: pageMediaList,
84 | NoMediasFound: len(pageMediaList) == 0,
85 | }
86 |
87 | var mediaListTemplate = template.Must(template.ParseFiles("ui/base.html", "ui/media-list.html"))
88 |
89 | if err := mediaListTemplate.Execute(w, mediaListPage); err != nil {
90 | logrus.Error(err)
91 | http.Error(w, err.Error(), http.StatusInternalServerError)
92 | }
93 |
94 | }
95 |
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: docker-build
2 | on:
3 | push:
4 | branches:
5 | - master
6 | jobs:
7 | build:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Prepare
11 | id: prepare
12 | run: |
13 | if [[ $GITHUB_REF == refs/tags/* ]]; then
14 | echo ::set-output name=version::${GITHUB_REF#refs/tags/v}
15 | elif [[ $GITHUB_REF == refs/heads/master ]]; then
16 | echo ::set-output name=version::latest
17 | elif [[ $GITHUB_REF == refs/heads/* ]]; then
18 | echo ::set-output name=version::${GITHUB_REF#refs/heads/}
19 | else
20 | echo ::set-output name=version::snapshot
21 | fi
22 | echo ::set-output name=build_date::$(date -u +'%Y-%m-%dT%H:%M:%SZ')
23 | echo ::set-output name=docker_platforms::linux/amd64,linux/arm/v7
24 | echo ::set-output name=docker_image::${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }}
25 | # https://github.com/crazy-max/ghaction-docker-buildx
26 | - name: Set up Docker Buildx
27 | id: buildx
28 | uses: crazy-max/ghaction-docker-buildx@v1
29 | with:
30 | version: latest
31 |
32 | - name: Environment
33 | run: |
34 | echo home=$HOME
35 | echo git_ref=$GITHUB_REF
36 | echo git_sha=$GITHUB_SHA
37 | echo version=${{ steps.prepare.outputs.version }}
38 | echo date=${{ steps.prepare.outputs.build_date }}
39 | echo image=${{ steps.prepare.outputs.docker_image }}
40 | echo platforms=${{ steps.prepare.outputs.docker_platforms }}
41 | echo avail_platforms=${{ steps.buildx.outputs.platforms }}
42 | # https://github.com/actions/checkout
43 | - name: Checkout
44 | uses: actions/checkout@v2
45 |
46 | - name: Docker Login
47 | if: success()
48 | env:
49 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
50 | run: |
51 | echo "${DOCKER_PASSWORD}" | docker login --username "${{ secrets.DOCKER_USERNAME }}" --password-stdin
52 | - name: Docker Buildx (push)
53 | if: success()
54 | run: |
55 | docker buildx build \
56 | --platform ${{ steps.prepare.outputs.docker_platforms }} \
57 | --output "type=image,push=true" \
58 | --build-arg "VERSION=${{ steps.prepare.outputs.version }}" \
59 | --build-arg "BUILD_DATE=${{ steps.prepare.outputs.build_date }}" \
60 | --build-arg "VCS_REF=${GITHUB_SHA}" \
61 | --tag "${{ steps.prepare.outputs.docker_image }}:${GITHUB_SHA}" \
62 | --tag "${{ steps.prepare.outputs.docker_image }}:latest" \
63 | --file Dockerfile .
64 | - name: Clear
65 | if: always()
66 | run: |
67 | rm -f ${HOME}/.docker/config.json
--------------------------------------------------------------------------------
/pkg/media/details/details_omdb.go:
--------------------------------------------------------------------------------
1 | package details
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "github.com/GaruGaru/ciak/pkg/media/models"
8 | "github.com/sirupsen/logrus"
9 | "net/http"
10 | "net/url"
11 | "regexp"
12 | "strconv"
13 | "strings"
14 | "time"
15 | )
16 |
17 | const (
18 | omdbEndpoint = "http://www.omdbapi.com"
19 | )
20 |
21 | var (
22 | ErrRatingProviderNotFound = errors.New("no ratings provider parser found")
23 | )
24 |
25 | type OmdbClient struct {
26 | apiKey string
27 | httpClient *http.Client
28 | endpoint string
29 | }
30 |
31 | func Omdb(apiKey string) *OmdbClient {
32 | return &OmdbClient{
33 | endpoint: omdbEndpoint,
34 | apiKey: apiKey,
35 | httpClient: &http.Client{
36 | Timeout: 10 * time.Second,
37 | },
38 | }
39 | }
40 |
41 | func (o OmdbClient) Details(request Request) (models.Details, error) {
42 | apiUrl := fmt.Sprintf("%s/?apikey=%s&t=%s", o.endpoint, o.apiKey, url.QueryEscape(normalizeTitle(request.Title)))
43 |
44 | resp, err := o.httpClient.Get(apiUrl)
45 |
46 | if err != nil {
47 | return models.Details{}, err
48 | }
49 |
50 | defer func() {
51 | if err := resp.Body.Close(); err != nil {
52 | logrus.Warn("unable to close response body: " + err.Error())
53 | }
54 | }()
55 |
56 | var movie OmdbMovie
57 |
58 | err = json.NewDecoder(resp.Body).Decode(&movie)
59 |
60 | if err != nil {
61 | return models.Details{}, err
62 | }
63 |
64 | if movie.Response == "False" {
65 | return models.Details{}, ErrDetailsNotFound
66 | }
67 |
68 | releaseDate, err := time.Parse("02 Jan 2006", movie.Released)
69 | if err != nil {
70 | logrus.Warnf("unable to parse media release date: %s", err)
71 | }
72 |
73 | ratingValue, ratingMax, err := parseRating(movie)
74 | if err != nil {
75 | logrus.Warnf("unable to parse media release date: %s", err)
76 | }
77 |
78 | return models.Details{
79 | Name: request.Title, // at the moment we use the name to associate request -> metadata
80 | Director: movie.Director,
81 | Genre: movie.Genre,
82 | Rating: ratingValue,
83 | MaxRating: ratingMax,
84 | ReleaseDate: releaseDate,
85 | ImagePoster: movie.Poster,
86 | }, nil
87 | }
88 |
89 | func parseRating(movie OmdbMovie) (float64, float64, error) {
90 | for _, r := range movie.Ratings {
91 | val, max, err := omdbParseProviderRating(r.Source, r.Value)
92 | if err != nil {
93 | if err != ErrRatingProviderNotFound {
94 | logrus.Warnf("error parsing rating: %s", err)
95 | }
96 | continue
97 | }
98 |
99 | return val, max, nil
100 | }
101 |
102 | return 0, 0, ErrRatingProviderNotFound
103 | }
104 |
105 | func omdbParseProviderRating(provider string, value string) (float64, float64, error) {
106 | switch provider {
107 | case "Rotten Tomatoes":
108 | return omdbParseRottenTomatoesRatings(value)
109 | default:
110 | return 0, 0, ErrRatingProviderNotFound
111 | }
112 | }
113 |
114 | func omdbParseRottenTomatoesRatings(value string) (float64, float64, error) {
115 | rawNum := strings.Replace(value, "%", "", 1)
116 | val, err := strconv.ParseFloat(rawNum, 64)
117 | if err != nil {
118 | return 0, 0, err
119 | }
120 | return val, 100, nil
121 | }
122 |
123 | func normalizeTitle(title string) string {
124 | removeYear := regexp.MustCompile(`(.*) (.*)(\\d\\d\\d\\d)`)
125 | matches := removeYear.FindAllString(title, -1)
126 | if len(matches) == 0 {
127 | return title
128 | }
129 | return matches[0]
130 | }
131 |
132 | type OmdbMovie struct {
133 | Title string `json:"Title"`
134 | Year string `json:"Year"`
135 | Rated string `json:"Rated"`
136 | Released string `json:"Released"`
137 | Runtime string `json:"Runtime"`
138 | Genre string `json:"Genre"`
139 | Director string `json:"Director"`
140 | Writer string `json:"Writer"`
141 | Actors string `json:"Actors"`
142 | Plot string `json:"Plot"`
143 | Language string `json:"Language"`
144 | Country string `json:"Country"`
145 | Awards string `json:"Awards"`
146 | Poster string `json:"Poster"`
147 | Ratings []struct {
148 | Source string `json:"Source"`
149 | Value string `json:"Value"`
150 | } `json:"Ratings"`
151 | Metascore string `json:"Metascore"`
152 | ImdbRating string `json:"imdbRating"`
153 | ImdbVotes string `json:"imdbVotes"`
154 | ImdbID string `json:"imdbID"`
155 | Type string `json:"Type"`
156 | DVD string `json:"DVD"`
157 | BoxOffice string `json:"BoxOffice"`
158 | Production string `json:"Production"`
159 | Website string `json:"Website"`
160 | Response string `json:"Response"`
161 | }
162 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/GaruGaru/duty v0.0.0-20190213134635-eef338d3b083 h1:aU/AWA+oEaGz0idVjWbfWZius7OxSH+awSYo/d/u5Dc=
2 | github.com/GaruGaru/duty v0.0.0-20190213134635-eef338d3b083/go.mod h1:w6mpIhC8WcdDIcCMoAhKZdW8uJX23wg8zf4nMQqoqOQ=
3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
7 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
8 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
9 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
10 | github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
11 | github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
12 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
13 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
14 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
15 | github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
16 | github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
17 | github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
18 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
19 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
20 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
21 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
22 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
23 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
24 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
25 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
26 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
27 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
28 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
29 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
30 | github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
31 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
32 | github.com/sirupsen/logrus v1.3.0 h1:hI/7Q+DtNZ2kINb6qt/lS+IyXnHQe9e90POfeewL/ME=
33 | github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
34 | github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8=
35 | github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
36 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
37 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
38 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
39 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
40 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
41 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
42 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
43 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
44 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
45 | go.etcd.io/bbolt v1.3.2 h1:Z/90sZLPOeCy2PwprqkFa25PdkusRzaj9P8zm/KNyvk=
46 | go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
47 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
48 | golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67 h1:ng3VDlRp5/DHpSWl02R4rM9I+8M2rhmsuLwAMmkLQWE=
49 | golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
50 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
51 | golang.org/x/sys v0.0.0-20190204203706-41f3e6584952 h1:FDfvYgoVsA7TTZSbgiqjAbfPbK47CNHdWl3h/PJtii0=
52 | golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
53 | golang.org/x/sys v0.0.0-20190213121743-983097b1a8a3 h1:+KlxhGbYkFs8lMfwKn+2ojry1ID5eBSMXprS2u/wqCE=
54 | golang.org/x/sys v0.0.0-20190213121743-983097b1a8a3/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
55 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
56 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
57 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
58 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
59 |
--------------------------------------------------------------------------------