├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── feature-request.md │ └── set-up-issues.md └── workflows │ └── docker-build.yml ├── .gitignore ├── .phan ├── config.php ├── curl.phan_php └── redis.phan_php ├── LICENSE ├── Readme.md ├── Setup.md ├── VERSION ├── docker-compose.dev.yml ├── docker-compose.yml ├── php ├── classes │ ├── Auth.php │ ├── Cache.php │ ├── Config.php │ ├── Data.php │ ├── Helper.php │ ├── Id.php │ ├── ImExport.php │ ├── Inner.php │ ├── JSONCache.php │ ├── Login.php │ ├── M3U.php │ ├── Output.php │ ├── OwnStreams.php │ ├── PodcastLoader.php │ ├── RadioBrowser.php │ ├── RadioLogo.php │ ├── RedisCache.php │ ├── Router.php │ ├── SimpleProxy.php │ ├── Template.php │ ├── UnRead.php │ ├── autoload.php │ └── templates │ │ ├── imexport.json │ │ ├── imexport_de.html │ │ ├── imexport_en.html │ │ ├── list.json │ │ ├── list_de.html │ │ ├── list_en.html │ │ ├── login.json │ │ ├── login_de.html │ │ ├── login_en.html │ │ ├── main.json │ │ ├── main_de.html │ │ ├── main_en.html │ │ ├── view.json │ │ ├── view_de.html │ │ └── view_en.html ├── data │ ├── env.json │ ├── podcasts_1.json │ └── radios_1.json ├── favicon.ico ├── gui │ ├── category.js │ ├── im-export.js │ ├── im-export.php │ ├── index.php │ ├── jquery.min.js │ ├── radio-browser.js │ ├── style.css │ ├── view.php │ └── viewer.js ├── image.php ├── index.php ├── m3u.php ├── media │ ├── default.png │ └── metal.png └── stream.php ├── screenshots ├── Readme.md ├── edit-podcast.png ├── edit-radio.png ├── login.png ├── preview-1.png └── preview-2.png └── utils ├── Dockerfile ├── backup-restore.php ├── cron.php ├── getr.php ├── nginx.conf ├── router.php ├── startup-before.sh └── startup.php /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: A bug report describes an error of Radio-API (not problems with the setup) 4 | title: '' 5 | labels: bug 6 | assignees: kimbtech 7 | 8 | --- 9 | 10 | > [!TIP] 11 | > Please also see [Troubleshooting](https://github.com/KIMB-technologies/Radio-API/blob/master/Setup.md#troubleshooting). 12 | 13 | **Describe the Bug** 14 | A clear and concise description of what the bug is. 15 | 16 | **To Reproduce** 17 | Steps to reproduce the behavior: 18 | 1. Go to ..., Click on ..., ... 19 | 2. Unsuccessful attempts to fix problem (i.e., troubleshooting) 20 | 21 | **Error Message or Log Files** 22 | Please add error message and/ or related messages from the log files (make sure to redact your mac, domain, and GUI-Code). 23 | 24 | **Radio-API Installation** 25 | - Version of Radio-API: 26 | - Installation mode: Docker or non-Docker 27 | - DNS redirect via: Radio-DNS or ... 28 | - Radio vendor: 29 | - Usage of reverse proxy: yes or no 30 | 31 | > [!WARNING] 32 | > If your bug is security related, please report it [here](https://github.com/KIMB-technologies/Radio-API/security). 33 | > Using this method, a fix can be published before the issue is made public! 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for Radio-API 4 | title: '' 5 | labels: enhancement 6 | assignees: kimbtech 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when ... 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/set-up-issues.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Set up Issues 3 | about: Questions or problems regarding the set up of Radio-API (not errors of Radio-API 4 | itself) 5 | title: '' 6 | labels: question 7 | assignees: kimbtech 8 | 9 | --- 10 | 11 | > [!TIP] 12 | > Please also see [Troubleshooting](https://github.com/KIMB-technologies/Radio-API/blob/master/Setup.md#troubleshooting). 13 | 14 | **Radio-API Installation** 15 | - Version of Radio-API: 16 | - Installation mode: Docker or non-Docker 17 | - DNS redirect via: Radio-DNS or ... 18 | - Radio vendor: 19 | - Usage of reverse proxy: yes or no 20 | - Does another radio use your installation of Radio-API: yes or no 21 | 22 | **Describe the Issue** 23 | - A clear and concise description of the issue. 24 | - Unsuccessful attempts to fix problem (i.e., troubleshooting) 25 | 26 | **Error Message or Log Files** 27 | Please add error message and/ or related messages from the log files (make sure to redact your mac, domain, and GUI-Code). 28 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image Push (for tags and on base image update) 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | schedule: 8 | - cron: "15 4 */3 * *" 9 | 10 | env: 11 | IMAGE_OWNER: kimbtechnologies 12 | IMAGE_NAME: radio_api 13 | BASE_IMAGE: kimbtechnologies/php_nginx:latest 14 | PLATFORMS: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/arm64 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | steps: 20 | 21 | # Init and check 22 | 23 | - name: Check for new baseimage 24 | id: check 25 | uses: lucacome/docker-image-update-checker@v1 26 | with: 27 | base-image: "${{env.BASE_IMAGE}}" 28 | image: "${{env.IMAGE_OWNER}}/${{env.IMAGE_NAME}}:latest" 29 | if: github.event_name != 'push' 30 | 31 | - name: Access repository contents 32 | uses: actions/checkout@v4 33 | if: ${{ (github.event_name == 'push') || (steps.check.outputs.needs-updating == 'true') }} 34 | - name: Login to DockerHub 35 | uses: docker/login-action@v3 36 | with: 37 | username: ${{ secrets.DOCKER_USERNAME }} 38 | password: ${{ secrets.DOCKER_TOKEN }} 39 | if: ${{ (github.event_name == 'push') || (steps.check.outputs.needs-updating == 'true') }} 40 | 41 | # Multi platform support 42 | 43 | - name: Set up QEMU for Docker Buildx 44 | uses: docker/setup-qemu-action@v3 45 | if: ${{ (github.event_name == 'push') || (steps.check.outputs.needs-updating == 'true') }} 46 | - name: Set up Docker Buildx 47 | uses: docker/setup-buildx-action@v3 48 | if: ${{ (github.event_name == 'push') || (steps.check.outputs.needs-updating == 'true') }} 49 | 50 | # Multi platform image build and push 51 | 52 | - name: Build and push the latest Docker image 53 | run: docker buildx build --platform "$PLATFORMS" . --file "./utils/Dockerfile" --tag "$IMAGE_OWNER/$IMAGE_NAME:latest" --push 54 | if: ${{ (github.event_name == 'push') || (steps.check.outputs.needs-updating == 'true') }} 55 | 56 | - name: Push more tags 57 | if: ${{ (github.event_name == 'push') || (steps.check.outputs.needs-updating == 'true') }} 58 | run: | 59 | cat VERSION | while read TAG; do 60 | if [[ $TAG =~ ^#.* ]] ; then 61 | echo "Skipping $TAG"; 62 | else 63 | echo "Tagging image as $TAG and pushing"; 64 | docker buildx build --platform "$PLATFORMS" . --file "./utils/Dockerfile" --tag "$IMAGE_OWNER/$IMAGE_NAME:$TAG" --push 65 | fi; 66 | done; 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /php/data/*.log 2 | /php/data/cache 3 | /php/getr.php 4 | 5 | /data/ 6 | /media/ 7 | /redis/ 8 | .DS_Store -------------------------------------------------------------------------------- /.phan/config.php: -------------------------------------------------------------------------------- 1 | '8.0', 15 | 'file_list' => [ 16 | './utils/cron.php', 17 | './utils/startup.php', 18 | './utils/getr.php' 19 | ], 20 | 'directory_list' => [ 21 | 'php' 22 | ], 23 | 'autoload_internal_extension_signatures' => [ 24 | 'redis' => './.phan/redis.phan_php', 25 | 'curl' => './.phan/curl.phan_php' 26 | ], 27 | 'backward_compatibility_checks' => true, 28 | 'plugins' => [ 29 | 'AlwaysReturnPlugin', 30 | 'DollarDollarPlugin', 31 | 'DuplicateArrayKeyPlugin', 32 | 'DuplicateExpressionPlugin', 33 | 'PregRegexCheckerPlugin', 34 | 'PrintfCheckerPlugin', 35 | 'SleepCheckerPlugin', 36 | 'UnreachableCodePlugin', 37 | 'UseReturnValuePlugin', 38 | 'EmptyStatementListPlugin', 39 | 'LoopVariableReusePlugin', 40 | ] 41 | ]; -------------------------------------------------------------------------------- /.phan/redis.phan_php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > Frontier Smart plans to shut down their Nuvola service: [More information](https://github.com/KIMB-technologies/Radio-API/releases/tag/v2.9.2-note). 3 | 4 | # Radio-API 5 | 6 | > Silicon Frontier, Frontier Silicon, or Frontier Nuvola (Smart) Internet Radio alternative API 7 | 8 | see https://github.com/kimbtech/WiFi-RadioAPI for information about the API used by the radios 9 | see https://hub.docker.com/r/kimbtechnologies/radio_api for the Docker Image 10 | 11 | ## About 12 | This is an alternative API for Frontier Nuvola (Frontier Silicon) internet radios, it can be placed on a server and will host the list of internet radio stations, podcasts etc. which then can be found in the radio's menu. 13 | 14 | The main idea is to redirect the HTTP request of the radio to another server, where own stations and podcasts can be added. 15 | This redirect is possible by manipulating the DNS queries. 16 | 17 | > This *Radio-API* uses the [RadioBrowser](https://www.radio-browser.info/) to provide a list of radio stations. 18 | > In addition, it allows each user to define their *own list of radio stations and podcasts*. 19 | > Audio streams from Nextcloud shares are supported, too. 20 | 21 | → [Have a look at **screenshots**](./screenshots/Readme.md) 22 | 23 | ## Usage 24 | - First [set up](./Setup.md) *Radio-API* and change the DNS resolver of the radio (e.g., as described there). 25 | - Afterwards start the radio and open "Internet Radio". 26 | - The *Radio-API* should provide a list of: 27 | - **Podcast** 28 | - This is the user defined list of podcasts. 29 | - The list available of podcasts can be changed using the GUI. 30 | - The list of episodes for each podcast is cached for `CONF_CACHE_EXPIRE` seconds. 31 | - The URL of a podcast can be an Atom RSS link or a link to a Nextcloud share. 32 | - Nextcloud share: 33 | - The system can fetch and stream audiofiles from Nextcloud shares. 34 | - The link of the share needs a to look like `mycloud.example.com/s//`. 35 | - All files in the shared folder will be shown in the radio as episode. 36 | - The share must not have a password. 37 | - There is no support for sub folders in shares, only the files in the share are shown. 38 | - Episodes get a `*` in front of their name if they have not yet been listened to. 39 | - **Radio** 40 | - This is the user defined list of internet radio stations. 41 | - The list of stations can be changed in the GUI. 42 | - A radio station should be an URL to some stream like MP3, M3U etc. 43 | - **Radio-Browser** 44 | - This allows to browse the radio stations in [RadioBrowser](https://www.radio-browser.info/). 45 | - Stations can be browsed by country (and state), language, tags, clicks and votes. 46 | - The stations recently opened via the radio are shown in *My Last*. 47 | - *Using Radio-Browser will send http requests and such usage data to the [RadioBrowser API](https://api.radio-browser.info/)!* 48 | - In the GUI it is also possible to search for stations in RadioBrowser and add them to the user defined stations. Stations from *My Last* are shown in the GUI, too. 49 | - **Stream** (if enabled in `docker-compose.yml`, see [Own Streams](./Setup.md#own-streams)) 50 | - This is a list of server specific streams. 51 | - The list is fetched from a custom url, provided in the Docker Container setup. 52 | - The Streams are shared across all radios using the same *Radio-API* setup. 53 | - **GUI-Code** 54 | - This code is like a password to access the GUI for this radio and edit the radio stations and streams. 55 | - GUI: 56 | - The GUI can be opened via a webbrowser at `radio.example.com/gui/`. 57 | - The GUI provides the editable lists of radio stations and podcasts. 58 | - A preview of the items shown by the radio is provided by the GUI, too. 59 | - The preview is also shown when opening `radio.example.com` in an browser and this browser has already logged into the GUI. 60 | - The `*` to mark new episodes can be toggled by the ✓/ ✗ in the preview. 61 | - Additional information texts describe the options chooseable for radio stations and podcasts. 62 | 63 | ### Notes 64 | - This is a private project and has no connections to Frontier Nuvola/ Frontier Silicon. 65 | - There is a limit of 1000 items per list: 1000 radio stations, 1000 streams, 1000 podcasts. 66 | Items from RadioBrowser do not count against this limit. 67 | Adding more than 200 user defined radio stations or podcasts is not recommended. 68 | - Nobody should host a public DNS resolver resolving wrong IPs. Some type of access control is recommended. 69 | 70 | ## Setup 71 | See [Setup.md](./Setup.md) including hints regarding updates, backups (i.e., Im- & Export), and troubleshooting. 72 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 2.9.4 2 | 2.9 3 | 2 4 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | # # # # # 2 | # File only for development !!! 3 | # # # # # 4 | 5 | version: "2" 6 | 7 | services: 8 | radio_api: 9 | build: 10 | context: . 11 | dockerfile: ./utils/Dockerfile 12 | container_name: radio_api_dev 13 | ports: 14 | - "8080:80" 15 | - "80:80" 16 | volumes: 17 | - ./php/:/php-code/ 18 | - ./data/:/php-code/data/ 19 | - ./media/:/php-code/media/ 20 | - ./utils/getr.php:/php-code/getr.php:ro # redis all values listing 21 | - ./utils/backup-restore.php:/backup-restore.php:ro # backup restore tool for cache values 22 | environment: 23 | - DEV=dev 24 | - CONF_DOMAIN=http://localhost:8080/ 25 | - CONF_RADIO_DOMAIN=http://127.0.0.1/ 26 | - CONF_ALLOWED_DOMAIN=all 27 | - CONF_SHUFFLE_MUSIC=true 28 | - CONF_CACHE_EXPIRE=1200 29 | #- CONF_USE_JSON_CACHE=true 30 | - CONF_REDIS_HOST=redis 31 | - CONF_STREAM_JSON=false #http://192.168.0.10:8081/list.json 32 | - CONF_IM_EXPORT_TOKEN=LP75Djdj195DL8SZnfY3 33 | - CONF_USE_LOGO_CACHE=true 34 | #- CONF_FAVORITE_ITEMS=Radio,Radio-Browser 35 | #- CONF_LEGACY_NEXTCLOUD=false # set to true for using nextcloud streams with nextcloud version < 31 36 | depends_on: 37 | - redis 38 | redis: 39 | image: redis:alpine 40 | container_name: radio_api_redis 41 | volumes: 42 | - ./redis/:/data 43 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | radio_api: 5 | image: kimbtechnologies/radio_api:latest 6 | container_name: radio_api 7 | restart: always 8 | ports: 9 | - "80:80" # make sure, Radio-API is avaliable at port 80 10 | volumes: 11 | - ./data/:/php-code/data/ # directory storing data, cache, and setting od Radio-API 12 | - ./media/:/php-code/media/ # directory storing images for stations 13 | environment: 14 | - CONF_DOMAIN=http://radio.example.com/ # the domain where the system is hosted 15 | # - CONF_RADIO_DOMAIN=http://hama.wifiradiofrontier.com # different domain to use for connections of the radio (if set CONF_DOMAIN is for GUI) 16 | - CONF_ALLOWED_DOMAIN=all # allowed ips for access, list of DynDNS domainnames (divided by ','), or 'all' to allow all ips 17 | - CONF_SHUFFLE_MUSIC=true # random shuffle music in nextcloud radio stations 18 | - CONF_CACHE_EXPIRE=1200 # cache expire time of ips, podcasts, ... 19 | - CONF_USE_JSON_CACHE=false # set to 'true' to disable redis cache and use a simple file-based cache 20 | - CONF_REDIS_HOST=redis # the redis hostname 21 | # - CONF_REDIS_PORT=6379 # default 6379 22 | # - CONF_REDIS_PASS= # default no auth 23 | - CONF_STREAM_JSON=false # to disable or an url where to fetch list of own streams, e.g., http://stream.example.com/list.json 24 | # - CONF_LOG_DIR= # set a custom directory for log files (defaults to ./data/) 25 | # - CONF_IM_EXPORT_TOKEN= # define a token for use with the im-, export web interface at ./gui/im-export.php 26 | - CONF_USE_LOGO_CACHE=true # cache logos (make them accessible without ssl and convert svgs to pngs for the radio, stores files in ./media/) 27 | # - CONF_FAVORITE_ITEMS=Radio,Radio-Browser # define favorite items, which will be listed first on radio 28 | depends_on: 29 | - redis 30 | 31 | # remove the following part, if 'CONF_USE_JSON_CACHE=true', i.e., redis is not used! 32 | redis: 33 | image: redis:alpine 34 | container_name: radio_api_redis 35 | restart: always 36 | #volumes: 37 | # - ./redis/:/data # redis is used as cache, so all data can be loaded from json files saved in radio_api 38 | -------------------------------------------------------------------------------- /php/classes/Auth.php: -------------------------------------------------------------------------------- 1 | No MAC!'); 37 | die(); //will never be reached 38 | } 39 | return $radioid; 40 | } 41 | 42 | 43 | 44 | } 45 | ?> -------------------------------------------------------------------------------- /php/classes/Cache.php: -------------------------------------------------------------------------------- 1 | Value 38 | // # # # # # 39 | 40 | /** 41 | * Does the key exist? 42 | * @param $key The key. 43 | * @param $value The value to store. 44 | * @param The time to live for the value. 45 | * @return The value 46 | */ 47 | public function set( string $key, string $value, int $ttl = 0 ): bool; 48 | 49 | /** 50 | * Does the key exists? 51 | * @param $key The key. 52 | * @return The value 53 | */ 54 | public function get( string $key ) : string; 55 | 56 | /** 57 | * Does the key exists? 58 | * @param $key The key. 59 | * @return exists? 60 | */ 61 | public function keyExists(string $key) : bool; 62 | 63 | /** 64 | * Removes a key. 65 | * @return successful? 66 | */ 67 | public function remove(string $key) : bool; 68 | 69 | // # # # # # 70 | // Key => Array (HashMap) 71 | // # # # # # 72 | 73 | /** 74 | * Sets an array into the cache. 75 | * We do a json_encode on deep arrays! 76 | * @param $key The key of the array 77 | * @param $array The array 78 | * @param $ttl The time to live for the array (0 => always) 79 | * @return successful stored? 80 | */ 81 | public function arraySet( string $key, array $array, int $ttl = 0 ) : bool; 82 | 83 | /** 84 | * Gets an array from the cache. 85 | * @param $key The key of the array 86 | * @return the array 87 | */ 88 | public function arrayGet( string $key ) : array; 89 | 90 | /** 91 | * Check an array for a key. 92 | * @param $key The key of the array 93 | * @param $arrayKey The key of the value in the array 94 | * @return Does the key exist? 95 | */ 96 | public function arrayKeyExists(string $key, string $arrayKey ) : bool; 97 | 98 | /** 99 | * Get value of a key of an array. 100 | * @param $key The key of the array 101 | * @param $arrayKey The key of the value in the array 102 | * @return The value 103 | */ 104 | public function arrayKeyGet(string $key, string $arrayKey ); 105 | 106 | /** 107 | * Set the value of one key in an array. 108 | * @param $key The key of the array 109 | * @param $arrayKey The key of the value in the array (null to append) 110 | * @param $value The value to store 111 | * @param $ttl The time to live for the entire array (0 => always) 112 | * @return successful stored? 113 | */ 114 | public function arrayKeySet(string $key, ?string $arrayKey, $value, int $ttl = 0 ) : bool; 115 | 116 | /** 117 | * Print all keys and values of this Group. 118 | */ 119 | public function output(): void; 120 | } 121 | 122 | /** 123 | * A class to cache values using redis or in json file. 124 | * If DOCKER_MODE=true -> use Redis, else use JSON. 125 | */ 126 | class Cache implements CacheInterface { 127 | 128 | private $s; 129 | 130 | public function __construct(string $group){ 131 | $this->s = DOCKER_MODE && !Config::USE_JSON_CACHE ? new RedisCache($group) : new JSONCache($group); 132 | } 133 | public function getAllKeysOfGroup() : array { 134 | return $this->s->getAllKeysOfGroup(); 135 | } 136 | 137 | public function removeGroup() : bool { 138 | return $this->s->removeGroup(); 139 | } 140 | 141 | public function set( string $key, string $value, int $ttl = 0 ): bool { 142 | return $this->s->set( $key, $value, $ttl ); 143 | } 144 | 145 | public function get( string $key ) : string { 146 | return $this->s->get( $key ); 147 | } 148 | 149 | public function keyExists(string $key) : bool { 150 | return $this->s->keyExists( $key ); 151 | } 152 | 153 | public function remove(string $key) : bool { 154 | return $this->s->remove( $key ); 155 | } 156 | 157 | public function arraySet( string $key, array $array, int $ttl = 0 ) : bool { 158 | return $this->s->arraySet( $key, $array, $ttl ); 159 | } 160 | 161 | public function arrayGet( string $key ) : array { 162 | return $this->s->arrayGet( $key ); 163 | } 164 | 165 | public function arrayKeyExists(string $key, string $arrayKey ) : bool { 166 | return $this->s->arrayKeyExists( $key, $arrayKey ); 167 | } 168 | 169 | public function arrayKeyGet(string $key, string $arrayKey ) { 170 | return $this->s->arrayKeyGet( $key, $arrayKey ); 171 | } 172 | 173 | public function arrayKeySet(string $key, ?string $arrayKey, $value, int $ttl = 0 ) : bool { 174 | return $this->s->arrayKeySet( $key, $arrayKey, $value, $ttl); 175 | } 176 | 177 | public function output(): void { 178 | $this->s->output(); 179 | } 180 | } 181 | 182 | ?> -------------------------------------------------------------------------------- /php/classes/Config.php: -------------------------------------------------------------------------------- 1 | 15 ? 63 | $ENV['CONF_IM_EXPORT_TOKEN'] : false 64 | ); 65 | define( 66 | 'ENV_USE_JSON_CACHE', 67 | !empty($ENV['CONF_USE_JSON_CACHE']) && $ENV['CONF_USE_JSON_CACHE'] == 'true' 68 | ); 69 | define( 70 | 'ENV_USE_LOGO_CACHE', 71 | !empty($ENV['CONF_USE_LOGO_CACHE']) && $ENV['CONF_USE_LOGO_CACHE'] == 'true' 72 | ); 73 | define( 'ENV_FAVORITE_ITEMS', 74 | !empty($ENV['CONF_FAVORITE_ITEMS']) ? 75 | strval($ENV['CONF_FAVORITE_ITEMS']) : '' 76 | ); 77 | define( 78 | 'ENV_LEGACY_NEXTCLOUD', 79 | !empty($ENV['CONF_LEGACY_NEXTCLOUD']) && $ENV['CONF_LEGACY_NEXTCLOUD'] == 'true' 80 | ); 81 | 82 | // IP on reverse proxy setup 83 | if( !empty($_SERVER['HTTP_X_REAL_IP']) ){ 84 | $_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP']; 85 | } 86 | // HTTPS or HTTP on reverse proxy setup 87 | if( !empty( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https' ){ 88 | $_SERVER['HTTPS'] = 'on'; 89 | } 90 | 91 | class Config { 92 | 93 | /** 94 | * The system's version. 95 | */ 96 | const VERSION = 'v2.9.4'; 97 | 98 | /** 99 | * The real domain which should be used. 100 | * Used for GUI 101 | */ 102 | const DOMAIN = ENV_DOMAIN; 103 | 104 | /** 105 | * The real domain which should be used. 106 | * Used for access of the radio. 107 | */ 108 | const RADIO_DOMAIN = ENV_RADIO_DOMAIN; 109 | 110 | /** 111 | * Seconds for cache lifetime 112 | */ 113 | const CACHE_EXPIRE = ENV_CACHE_EXPIRE; 114 | 115 | /** 116 | * Own Stream used? 117 | */ 118 | const STREAM_JSON = ENV_STREAM_JSON; 119 | 120 | /** 121 | * Random shuffle music station streams from nc 122 | */ 123 | const SHUFFLE_MUSIC = ENV_SHUFFLE_MUSIC; 124 | 125 | /** 126 | * The directory where the logfiles are stored. 127 | */ 128 | const LOG_DIR = ENV_LOG_DIR; 129 | 130 | /** 131 | * The directory used by the json cache (replacement for Redis in non-Docker mode) 132 | */ 133 | const CACHE_DIR = ENV_CACHE_DIR; 134 | 135 | /** 136 | * Im- & Export via web GUI (at ./gui/im-export.php) 137 | */ 138 | const IM_EXPORT_TOKEN = ENV_IM_EXPORT_TOKEN; 139 | 140 | /** 141 | * Always use json cache, even in Docker-Mode 142 | */ 143 | const USE_JSON_CACHE = ENV_USE_JSON_CACHE; 144 | 145 | /** 146 | * Cache logos and make them accessible without ssl. 147 | */ 148 | const USE_LOGO_CACHE = ENV_USE_LOGO_CACHE; 149 | 150 | /** 151 | * A list of items to move up in list, because they are favorites. 152 | */ 153 | const FAVORITE_ITEMS = ENV_FAVORITE_ITEMS; 154 | 155 | /** 156 | * Nextcloud version 31.0.0 changes how files can be downloaded from shares, use the old way required by older version of Nextcloud servers? 157 | */ 158 | const LEGACY_NEXTCLOUD = ENV_LEGACY_NEXTCLOUD; 159 | 160 | /** 161 | * Store redis cache for ALLOWED_DOMAINS 162 | */ 163 | private static $redisAccessDomains = null; 164 | 165 | /** 166 | * Store latest version and update available status 167 | */ 168 | private static $redisUpdateStatus = null; 169 | 170 | /** 171 | * Checks if access allowed (for this request) 172 | * Has to end the script, if not allowed! 173 | * @param $mac give the users mac (we will test his last domain first, to speed up things) 174 | */ 175 | public static function checkAccess( ?string $mac = null ) : void { 176 | if( is_null( self::$redisAccessDomains ) ){ // already loaded? 177 | if(DOCKER_MODE){ // use the preloaded values from ./startup.php 178 | self::setRedisServer(); 179 | self::$redisAccessDomains = new Cache( 'allowed_domains' ); 180 | } 181 | else { // load the values from ENV 182 | self::parseAllowedDomain(); 183 | } 184 | } 185 | 186 | if( self::$redisAccessDomains->get('type' ) == 'all' ){ // allow all 187 | return; 188 | } 189 | else if( self::$redisAccessDomains->get('type' ) == 'list' ){ // check list 190 | $ip = $_SERVER["REMOTE_ADDR"]; // get client ip 191 | 192 | // iterate over all allowed domains, and check all ips (which are not timed out) 193 | $checklater = array(); 194 | foreach( self::$redisAccessDomains->arrayGet('domains') as $domain ){ 195 | if( self::$redisAccessDomains->keyExists( 'ip_for_domain.' . $domain ) ){ 196 | if( self::$redisAccessDomains->get( 'ip_for_domain.' . $domain ) == $ip ){ 197 | if( !is_null( $mac ) ){ // save this domain for this user 198 | self::$redisAccessDomains->set( 'domain_for_user.' . $mac, $domain ); 199 | } 200 | return; // access granted 201 | } 202 | } 203 | $checklater[] = $domain; 204 | } 205 | 206 | // the last domain for this user should be check first 207 | if( !is_null( $mac ) && count( $checklater ) > 1 && self::$redisAccessDomains->keyExists( 'domain_for_user.' . $mac ) ){ 208 | $pos = array_search( self::$redisAccessDomains->get( 'domain_for_user.' . $mac ), $checklater ); 209 | if( $pos !== false ){ 210 | $tmp = $checklater[$pos]; // swap positions, so last domain ist first 211 | $checklater[$pos] = $checklater[0]; 212 | $checklater[0] = $tmp; 213 | } 214 | } 215 | 216 | foreach( $checklater as $domain ){ 217 | $thisip = gethostbyname( $domain ); 218 | self::$redisAccessDomains->set( 'ip_for_domain.' . $domain, $thisip, self::CACHE_EXPIRE ); 219 | if( $thisip == $ip ){ // ip ok? 220 | return; 221 | } 222 | } 223 | die('Not Allowed!'); 224 | } 225 | else{ 226 | die('Invalid Access Domains!'); 227 | } 228 | } 229 | 230 | /** 231 | * Sets the redis server connection details using the env vars. 232 | * Should be always called before creating a RedisCache. 233 | */ 234 | public static function setRedisServer() : void { 235 | // Redis only in Docker mode and if not USE_JSON_CACHE 236 | if(DOCKER_MODE && !self::USE_JSON_CACHE){ 237 | // configure redis 238 | if( isset( $_ENV['CONF_REDIS_HOST'], $_ENV['CONF_REDIS_PORT'], $_ENV['CONF_REDIS_PASS'] ) ){ 239 | RedisCache::setRedisServer($_ENV['CONF_REDIS_HOST'], $_ENV['CONF_REDIS_PORT'], $_ENV['CONF_REDIS_PASS']); 240 | } 241 | else if( isset( $_ENV['CONF_REDIS_HOST'], $_ENV['CONF_REDIS_PORT'] ) ){ 242 | RedisCache::setRedisServer($_ENV['CONF_REDIS_HOST'], $_ENV['CONF_REDIS_PORT']); 243 | } 244 | else if( isset( $_ENV['CONF_REDIS_HOST'] ) ){ 245 | RedisCache::setRedisServer($_ENV['CONF_REDIS_HOST']); 246 | } 247 | } 248 | } 249 | 250 | public static function updateAvailable() : bool { 251 | if( is_null( self::$redisUpdateStatus ) ){ // load redis, if not loaded 252 | self::setRedisServer(); 253 | self::$redisUpdateStatus = new Cache( 'update_status' ); 254 | } 255 | 256 | // remove values of "old" update indicator 257 | if(self::$redisUpdateStatus->keyExists('update_available')){ 258 | self::$redisUpdateStatus->remove('update_available'); 259 | self::$redisUpdateStatus->remove('latest_version'); 260 | } 261 | 262 | // check for new information from GitHub API 263 | if(!self::$redisUpdateStatus->keyExists('latest_version')){ 264 | $infos = json_decode(file_get_contents( 265 | 'https://api.github.com/repos/KIMB-technologies/Radio-API/releases/latest', 266 | false, 267 | stream_context_create(array('http' =>array( 268 | 'method' => 'GET', 269 | 'header' => "Content-Type: application/json\r\n". "User-Agent: KIMB-technologies/Radio-API\r\n", 270 | 'timeout' => 4 271 | ))) 272 | ), true); 273 | 274 | if(is_null($infos)){ 275 | // error checking latest version 276 | return false; 277 | } 278 | else{ 279 | self::$redisUpdateStatus->set( 280 | 'latest_version', 281 | $infos["tag_name"], 282 | 60*60*24*3 // check every 3 days 283 | ); 284 | self::$redisUpdateStatus->set( 285 | 'last_check', 286 | date('d.m.Y H:i:s') 287 | ); 288 | } 289 | } 290 | 291 | return version_compare(self::VERSION, self::$redisUpdateStatus->get('latest_version'), '<'); 292 | } 293 | 294 | public static function parseAllowedDomain(bool $output = false) : void { 295 | self::setRedisServer(); 296 | self::$redisAccessDomains = new Cache( 'allowed_domains' ); 297 | 298 | if( ENV_ALLOWED_DOMAIN == 'all' ){ 299 | self::$redisAccessDomains->set( 'type', 'all' ); 300 | } 301 | else if( !is_null( ENV_ALLOWED_DOMAIN ) ){ 302 | self::$redisAccessDomains->set( 'type', 'list' ); 303 | $allowed = array_map( function ($domain){ 304 | return trim($domain); 305 | }, explode(',', ENV_ALLOWED_DOMAIN ) ); 306 | self::$redisAccessDomains->arraySet('domains', $allowed); 307 | } 308 | else{ 309 | self::$redisAccessDomains->set( 'type', 'error' ); 310 | } 311 | 312 | if($output){ 313 | self::$redisAccessDomains->output(); 314 | } 315 | } 316 | } 317 | 318 | ?> 319 | -------------------------------------------------------------------------------- /php/classes/Data.php: -------------------------------------------------------------------------------- 1 | id = $id; 29 | $this->redis = new Cache('radios_podcasts.' . $this->id ); 30 | $this->own_streams = new OwnStreams(); 31 | 32 | if( !$this->redis->keyExists( 'types' ) || $preload ){ 33 | $this->preloadAll(); 34 | if( !$this->redis->keyExists( 'types' ) ){ 35 | $this->constructTable(); 36 | } 37 | } 38 | } 39 | 40 | /** 41 | * Load files from disk 42 | */ 43 | private function preloadAll() : void { 44 | $radio = null; 45 | if(is_file( __DIR__ . '/../data/radios_'. $this->id .'.json' )){ 46 | $radio = json_decode( file_get_contents( __DIR__ . '/../data/radios_'. $this->id .'.json' ), true); 47 | if(is_null($radio)){ // on json error, move file and create new 48 | rename(__DIR__ . '/../data/radios_'. $this->id .'.json', __DIR__ . '/../data/radios_'. $this->id .'.error.json'); 49 | } 50 | } 51 | $this->radio = is_null($radio) ? array() : $radio; 52 | 53 | $podcasts = null; 54 | if(is_file( __DIR__ . '/../data/podcasts_'. $this->id .'.json' )){ 55 | $podcasts = json_decode( file_get_contents( __DIR__ . '/../data/podcasts_'. $this->id .'.json' ), true); 56 | if(is_null($podcasts)){ 57 | rename(__DIR__ . '/../data/podcasts_'. $this->id .'.json', __DIR__ . '/../data/podcasts_'. $this->id .'.error.json'); 58 | } 59 | } 60 | $this->podcasts = is_null($podcasts) ? array() : $podcasts; 61 | 62 | $this->preloaded = true; 63 | } 64 | 65 | /** 66 | * Generate the main Table of Stations 67 | */ 68 | private function constructTable() : void { 69 | if(!$this->preloaded){ // load data, in not already done 70 | $this->preloadAll(); 71 | } 72 | 73 | // generate Table 74 | $this->table = array(); 75 | $this->table['types'] = array( 76 | 1 => 'Radio', 77 | 3 => 'Podcast' 78 | ); 79 | $this->table['items'] = array(); 80 | 81 | // add radio, podcasts 82 | $this->addTypeToTable( 1, $this->radio ); 83 | $this->addTypeToTable( 3, $this->podcasts ); 84 | 85 | // add "own streams" 86 | if( Config::STREAM_JSON ){ 87 | $this->table['types'][2] = 'Stream'; 88 | $this->addTypeToTable( 2, $this->own_streams->getStreams() ); 89 | } 90 | 91 | // save in redis 92 | // if using own stream, we need to give a ttl, else the system won't reload the list of own streams 93 | $this->redis->arraySet( 'types', $this->table['types'], Config::STREAM_JSON ? Config::CACHE_EXPIRE : 0 ); 94 | $this->redis->arraySet( 'items', $this->table['items'], Config::STREAM_JSON ? Config::CACHE_EXPIRE : 0 ); 95 | } 96 | 97 | /** 98 | * Add a type to table 99 | * Helper for constructTable 100 | */ 101 | private function addTypeToTable( int $tid, array $data ) : void{ 102 | foreach( $data as $id => $d ){ 103 | $idd = $id + 1000 * $tid; 104 | $this->table['items'][$idd] = $d; 105 | $this->table['items'][$idd]['tid'] = $tid; 106 | if( $id >= 999 ){ // only 999 per type!! 107 | break; 108 | } 109 | } 110 | } 111 | 112 | /** 113 | * Returns List of type, indexed by typeID 114 | */ 115 | public function getTypes() : array { 116 | return $this->redis->arrayGet('types'); 117 | } 118 | 119 | /** 120 | * Returns List of categories for a typeID 121 | */ 122 | public function getCategories(?int $tid = null) : array { 123 | $cats = array_filter( 124 | $this->redis->arrayGet('items'), 125 | fn($i) => (is_null($tid) || $tid == $i['tid']) && !empty($i["category"]) 126 | ); 127 | return array_unique(array_map(fn($i) => $i["category"], $cats)); 128 | } 129 | 130 | /** 131 | * Returns list of items in this type, indexed by id! 132 | * Filter for a category or set "null" to return all categories 133 | */ 134 | public function getListOfItems(?int $tid = null, ?string $cat = null ) : array { 135 | return array_filter( 136 | $this->redis->arrayGet('items'), 137 | fn($i) => (is_null($tid) || $tid == $i['tid']) && ( (is_null($cat) && empty($i['category'])) || $cat == $i['category']) 138 | ); 139 | } 140 | 141 | /** 142 | * Get data of one item by its id. 143 | */ 144 | public function getById( int $id ) : array { 145 | if( !$this->redis->arrayKeyExists('items', $id) ){ 146 | return array(); 147 | } 148 | return $this->redis->arrayKeyGet('items', $id); 149 | } 150 | 151 | /** 152 | * Generate Link for station 153 | * @param $id radio station id 154 | * @param $mac users radio mac 155 | */ 156 | public function getStationURL( int $id, string $mac ) : string { 157 | $station = $this->getById($id); 158 | if(empty($station)){ 159 | return ""; 160 | } 161 | if(!empty($station['type']) && $station['type'] == 'nc' ){ 162 | return Config::RADIO_DOMAIN . 'm3u.php?id=' . $id . '&mac=' . $mac; 163 | } 164 | else if(!empty($station['proxy'])){ 165 | return Config::RADIO_DOMAIN . 'stream.php?id=' . $id . '&mac=' . $mac; 166 | } 167 | else{ 168 | return $station['url']; 169 | } 170 | } 171 | 172 | /** 173 | * Generate Link for podcast 174 | * @param $id podcast id 175 | * @param $eid episode id 176 | * @param $mac users radio mac 177 | * @param $sloppy (default=false) do not perform endURL lookup 178 | */ 179 | public function getPodcastURL( int $id, int $eid, string $mac, bool $sloppy = false ) : string { 180 | $ed = PodcastLoader::getEpisodeData( $id, $eid, $this ); 181 | 182 | if(empty($ed)){ 183 | return ""; 184 | } 185 | 186 | if($ed['proxy']){ 187 | return Config::RADIO_DOMAIN . 'stream.php?id=' . $id . '&eid=' . $eid . '&mac=' . $mac; 188 | } 189 | else if($ed['finalurl'] && !$sloppy){ 190 | return Helper::getFinalUrl($ed['episode']['url']); 191 | } 192 | else{ 193 | return $ed['episode']['url']; 194 | } 195 | } 196 | 197 | /** 198 | * Backend Raw Access 199 | */ 200 | public function getRadioList() : array { 201 | if(!$this->preloaded){ 202 | $this->preloadAll(); 203 | } 204 | return $this->radio; 205 | } 206 | public function getPodcastList() : array { 207 | if(!$this->preloaded){ 208 | $this->preloadAll(); 209 | } 210 | return $this->podcasts; 211 | } 212 | public function setRadioList(array $radios) : void { 213 | $this->radio = $radios; 214 | file_put_contents( __DIR__ . '/../data/radios_'. $this->id .'.json', json_encode($this->radio, JSON_PRETTY_PRINT), LOCK_EX); 215 | $this->constructTable(); // update redis 216 | } 217 | public function setPodcastList(array $pods) : void { 218 | $this->podcasts = $pods; 219 | file_put_contents( __DIR__ . '/../data/podcasts_'. $this->id .'.json', json_encode($this->podcasts, JSON_PRETTY_PRINT), LOCK_EX); 220 | $this->constructTable(); // update redis 221 | } 222 | } 223 | ?> 224 | -------------------------------------------------------------------------------- /php/classes/Helper.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /php/classes/Id.php: -------------------------------------------------------------------------------- 1 | podcasts files 36 | //mac => radio id 37 | //code => gui access 38 | //data => [mac, code] 39 | 40 | public static function isIdInteger(int $i) : bool { 41 | return $i > 0 && $i < 10_000; 42 | } 43 | 44 | public static function getTableData() : array { 45 | $table = null; 46 | if( is_file( __DIR__ . '/../data/table.json' ) ){ // load table form disk? 47 | $table = json_decode(file_get_contents( __DIR__ . '/../data/table.json' ), true); 48 | 49 | if(is_null($table)){ // on json error, move file and create new 50 | rename(__DIR__ . '/../data/table.json', __DIR__ . '/../data/table.error.json'); 51 | } 52 | } 53 | if ( is_null($table) ) { // init empty table 54 | $table = array( 55 | 'macs' => array(), // mac => id 56 | 'ids' => array(), // id => [ mac, code ] 57 | 'codes' => array() // code => id 58 | ); 59 | // save init table 60 | file_put_contents( __DIR__ . '/../data/table.json', json_encode($table, JSON_PRETTY_PRINT), LOCK_EX); 61 | } 62 | 63 | return $table; 64 | } 65 | 66 | public function __construct($val, int $type = self::MAC){ 67 | // load redis 68 | $redis = new Cache('table.json'); 69 | // import table 70 | if( !$redis->keyExists('ids') ){ 71 | $this->loadFileIntoRedis($redis); 72 | } 73 | 74 | // get id from given data 75 | if( $type === self::CODE && Helper::checkValue( $val, self::CODE_PREG ) ){ 76 | if( $redis->arrayKeyExists('codes', $val ) ){ 77 | $this->id = $redis->arrayKeyGet('codes', $val ); // get ID 78 | } 79 | else{ 80 | throw new Exception('Unknown Code, use Radio Mac to create!'); 81 | } 82 | } 83 | else if( $type === self::MAC && Helper::checkValue( $val, self::MAC_PREG ) ){ 84 | //check if new mac 85 | if( $redis->arrayKeyExists('macs', $val ) ){ 86 | $this->id = $redis->arrayKeyGet('macs', $val ); // get ID 87 | } 88 | else{ 89 | $this->id = $this->generateNewId($val, $redis); 90 | } 91 | } 92 | else if( $type === self::ID && Helper::checkValue( $val, self::ID_PREG ) ){ 93 | $this->id = $val; 94 | } 95 | else{ 96 | throw new Exception('Invalid Format'); 97 | } 98 | 99 | //load this data by id 100 | if( $redis->arrayKeyExists('ids', $this->id ) ){ 101 | $this->data = $redis->arrayKeyGet('ids', $this->id ); 102 | } 103 | else{ 104 | throw new Exception('Unknown ID, use Radio Mac to create!'); 105 | } 106 | } 107 | 108 | public function getId() : int { 109 | return $this->id; 110 | } 111 | 112 | public function getMac() : string { 113 | return $this->data[0]; 114 | } 115 | 116 | public function getCode() : string { 117 | return $this->data[1]; 118 | } 119 | 120 | private function loadFileIntoRedis(Cache $redis) : void { 121 | $table = self::getTableData(); 122 | 123 | // set also in redis 124 | $redis->arraySet('macs', $table['macs'], self::CACHE_TTL); 125 | $redis->arraySet('ids', $table['ids'], self::CACHE_TTL); 126 | $redis->arraySet('codes', $table['codes'], self::CACHE_TTL); 127 | } 128 | 129 | private function generateNewId( string $val, Cache $redis ) : int { 130 | // Load file, as file it the primary storage 131 | $table = self::getTableData(); 132 | 133 | // new id 134 | $id = count( $table['ids'] ) + 1; 135 | 136 | // new code 137 | do{ 138 | $code = 'Z' . Helper::randomCode( 4 ); 139 | } while( isset( $table['codes'][$code] ) ); 140 | 141 | // alter table 142 | $table['ids'][$id] = array( 143 | // mac, code 144 | $val, $code 145 | ); 146 | $table['macs'][$val] = $id; 147 | $table['codes'][$code] = $id; 148 | 149 | // save new table 150 | // File 151 | file_put_contents( __DIR__ . '/../data/table.json', json_encode($table, JSON_PRETTY_PRINT), LOCK_EX); 152 | // Redis 153 | $redis->arraySet('macs', $table['macs'], self::CACHE_TTL); 154 | $redis->arraySet('ids', $table['ids'], self::CACHE_TTL); 155 | $redis->arraySet('codes', $table['codes'], self::CACHE_TTL); 156 | 157 | return $id; 158 | } 159 | } 160 | 161 | ?> -------------------------------------------------------------------------------- /php/classes/ImExport.php: -------------------------------------------------------------------------------- 1 | msg; 26 | } 27 | 28 | private function addToExport(string $dir, array &$export, bool $rm = false) : void { 29 | foreach(scandir($dir) as $f){ 30 | if($this->exportFile($f)){ 31 | $export[$f] = json_decode(file_get_contents($dir . '/' . $f), true); 32 | 33 | if($rm){ 34 | unlink($dir . '/' . $f); 35 | } 36 | } 37 | } 38 | } 39 | 40 | private function exportFile(string $name, ?string $checkKind = null) : bool { 41 | $kind = null; 42 | switch ($name){ 43 | case "table.json": 44 | $kind = "table"; 45 | break; 46 | case "env.json": 47 | $kind = "env"; 48 | break; 49 | case "radiobrowser.json": 50 | case "unread.json": 51 | $kind = "cache"; 52 | break; 53 | default: 54 | if(preg_match('/^(?:radio|podcast)s_[0-9]{1,4}\.json$/', $name) === 1){ 55 | $kind = "list"; 56 | } 57 | break; 58 | } 59 | 60 | return !is_null($kind) && (is_null($checkKind) || $checkKind === $kind); 61 | } 62 | 63 | private function getTmpDir() : string { 64 | $tmpDir = sys_get_temp_dir() . '/RadioAPI-' . Helper::randomCode(10); 65 | mkdir($tmpDir); 66 | return $tmpDir; 67 | } 68 | 69 | public function export(bool $yield = true) { 70 | // dump from cache/ redis 71 | // create tmp dir 72 | $tmpDir = $this->getTmpDir(); 73 | // dump 74 | UnRead::dumpToDisk($tmpDir); 75 | RadioBrowser::dumpToDisk($tmpDir); 76 | 77 | // create the export array 78 | $export = array( 79 | "__version__" => Config::VERSION, 80 | "__date__" => date('d.m.Y H:i:s') 81 | ); 82 | $this->addToExport(__DIR__ . '/../data', $export); 83 | $this->addToExport($tmpDir, $export, true); 84 | 85 | // remove tmp dir 86 | rmdir($tmpDir); 87 | 88 | if($yield){ 89 | // encode as json 90 | $json = json_encode($export, JSON_PRETTY_PRINT); 91 | 92 | // yield header 93 | header('Content-Type: application/json;charset=UTF-8'); 94 | header('Content-Disposition: attachment; filename=Radio-API_export_'.date('Y-m-d_H-i-s').'.json' ); 95 | header('Content-Length: ' . strlen( $json )); 96 | header('Cache-Control: no-store, no-cache, must-revalidate'); 97 | header('Pragma: no-cache'); 98 | // yield the data 99 | echo $json; 100 | } 101 | else{ 102 | return $json; 103 | } 104 | } 105 | 106 | private function validateExport(array $export) : bool { 107 | $ok = true; 108 | foreach($export as $f => $content){ 109 | if($this->exportFile($f, "list")){ 110 | $ok &= $this->validateList($content); 111 | if(!$ok){ 112 | $this->msg = (Template::getLanguage() == 'de' ? "Fehlerhafte Liste" : "Error in list") . ": " . $f; 113 | break; 114 | } 115 | } 116 | else if($this->exportFile($f, "cache")){ 117 | $ok &= $this->validateCache($content, $f); 118 | if(!$ok){ 119 | $this->msg = (Template::getLanguage() == 'de' ? "Fehlerhafter Cache" : "Error in cache") . ": " . $f; 120 | break; 121 | } 122 | } 123 | else if($this->exportFile($f, "table")){ 124 | $ok &= $this->validateTable($content); 125 | if(!$ok){ 126 | $this->msg = Template::getLanguage() == 'de' ? "Fehlerhafte Tabelle" : "Error in table"; 127 | break; 128 | } 129 | } 130 | else if($f == "__version__" || $f == "__date__"){ 131 | // ignore 132 | } 133 | else if(!$this->exportFile($f, "env")){ // env is ignored, other ones not allowed! 134 | $ok = false; 135 | $this->msg = Template::getLanguage() == 'de' ? "Unbekannte Daten in Export" : "Unknown data in export!"; 136 | break; 137 | } 138 | } 139 | 140 | return $ok; 141 | } 142 | 143 | private function validateList(array $content) : bool { 144 | $ok = true; 145 | $cnt = 0; 146 | foreach($content as $key => $val){ 147 | $ok &= $key === $cnt && is_array($val); 148 | 149 | foreach($val as $k => $v){ 150 | switch ($k){ 151 | case "name": 152 | case "desc": 153 | $ok &= Inner::filterName($v) === $v; 154 | break; 155 | case "category": 156 | $ok &= ($v === "" || Inner::filterCategory($v) === $v); 157 | break; 158 | case "logo": 159 | case "url": 160 | $ok &= Inner::filterURL($v) === $v; 161 | break; 162 | case "type": 163 | $ok &= in_array($v, ["rss", "nc", "radio"]); 164 | break; 165 | case "finalurl": 166 | case "proxy": 167 | $ok &= is_bool($v); 168 | break; 169 | default: 170 | $ok = false; 171 | break; 172 | } 173 | } 174 | $ok &= array_key_exists("name", $val) && array_key_exists("url", $val) && array_key_exists("type", $val); 175 | 176 | $cnt += 1; 177 | } 178 | return $ok; 179 | } 180 | 181 | private function validateCache(array $content, string $name) : bool { 182 | $ok = true; 183 | foreach($content as $key => $value){ 184 | $ok &= Id::isIdInteger($key) && is_array($value); 185 | 186 | if($name == "unread.json"){ 187 | $ok &= array_reduce( 188 | $value, 189 | fn($c, $i) => $c && Inner::filterURL($i) === $i, 190 | true 191 | ); 192 | } 193 | else { // radiobrowser.json 194 | foreach($value as $k => $v){ 195 | $ok &= is_string($k) && RadioBrowser::uuidFromStationID(RadioBrowser::stationIDfromUUID($k)) === $k; 196 | 197 | $ok &= array_key_exists("name", $v) && array_key_exists("url", $v) && array_key_exists("time", $v); 198 | $ok &= is_string($v["name"]) && Inner::filterURL($v["url"]) === $v["url"] && is_integer($v["time"]); 199 | } 200 | } 201 | } 202 | return $ok; 203 | } 204 | 205 | private function validateTable(array $content) : bool { 206 | $ok = array_key_exists("macs", $content) && array_key_exists("ids", $content) && array_key_exists("codes", $content); 207 | if($ok){ 208 | $macs = array_filter( 209 | $content["macs"], 210 | fn($v, $k) => Helper::checkValue( $k, Id::MAC_PREG ) && is_integer($v) && Id::isIdInteger($v), 211 | ARRAY_FILTER_USE_BOTH 212 | ); 213 | $ok &= count($macs) === count($content["macs"]); 214 | 215 | $codes = array_filter( 216 | $content["codes"], 217 | fn($v, $k) => Helper::checkValue( $k, Id::CODE_PREG ) && is_integer($v) && Id::isIdInteger($v), 218 | ARRAY_FILTER_USE_BOTH 219 | ); 220 | $ok &= count($codes) === count($content["codes"]); 221 | 222 | $ids = array_filter( 223 | $content["ids"], 224 | fn($v, $k) => Id::isIdInteger($k) && is_array($v) && count($v) === 2 && 225 | Helper::checkValue( $v[0], Id::MAC_PREG ) && Helper::checkValue( $v[1], Id::CODE_PREG ), 226 | ARRAY_FILTER_USE_BOTH 227 | ); 228 | $ok &= count($ids) === count($content["ids"]); 229 | } 230 | return $ok; 231 | } 232 | 233 | public function import(string $exportfile, string $kind, ?string $codeExport, ?string $codeSystem) : bool { 234 | $export = json_decode(file_get_contents($exportfile), true); 235 | if(!is_array($export)){ 236 | $this->msg = Template::getLanguage() == 'de' ? "Kann Export-Datei nicht lesen!" : "Unable to open Export file!"; 237 | return false; 238 | } 239 | if(!in_array($kind, ["append", "single", "replace"])){ 240 | $this->msg = Template::getLanguage() == 'de' ? "Art des Imports unbekannt!" : "Kind of report unknown!"; 241 | return false; 242 | } 243 | if($kind === "single"){ 244 | if ( is_null($codeExport) || is_null($codeSystem) ){ 245 | $this->msg = Template::getLanguage() == 'de' ? "Einzelner Import benötigt zwei GUI-Codes!" : "Single import requires two GUI-Codes."; 246 | return false; 247 | } 248 | else if ( !Helper::checkValue( $codeExport, Id::CODE_PREG ) || !Helper::checkValue( $codeSystem, Id::CODE_PREG ) ){ 249 | $this->msg = Template::getLanguage() == 'de' ? "Ein GUI-Code ist ungültig." : "A GUI-Code is invalid."; 250 | return false; 251 | } 252 | } 253 | 254 | if( 255 | !array_key_exists("unread.json", $export) || 256 | !array_key_exists("radiobrowser.json", $export) || 257 | !array_key_exists("table.json", $export) 258 | ){ 259 | $this->msg = ( 260 | Template::getLanguage() == 'de' ? 261 | "Daten im Export fehlen, mindestens notwendig" : "Data in export missing, at least required" 262 | ) . ": unread.json, radiobrowser.json, table.json"; 263 | return false; 264 | } 265 | 266 | if(!$this->validateExport($export)){ 267 | return false; 268 | } 269 | 270 | switch($kind){ 271 | case "replace": 272 | return $this->runReplace($export); 273 | case "append": 274 | return $this->runAppend($export); 275 | case "single": 276 | return $this->runSingle($export, $codeExport, $codeSystem); 277 | } 278 | } 279 | 280 | private function runReplace(array $export, bool $cleanUp = true) : bool { 281 | $ok = true; 282 | 283 | $dataDir = realpath(__DIR__ . '/../data'); 284 | $tmpDir = $this->getTmpDir(); 285 | 286 | if($cleanUp){ 287 | // clean up data dir 288 | foreach(scandir($dataDir) as $f){ 289 | if($this->exportFile($f, "table") || $this->exportFile($f, "list") ){ 290 | $ok &= unlink($dataDir . '/' . $f); 291 | } 292 | } 293 | if(!$ok){ 294 | $this->msg .= "
" . (Template::getLanguage() == 'de' ? "Vorbereiten schlug fehl!" : "Error during preparation!"); 295 | } 296 | } 297 | 298 | // write files 299 | foreach($export as $f => $data ){ 300 | if($this->exportFile($f, "table") || $this->exportFile($f, "list") ){ 301 | $file = $dataDir . '/' . $f; 302 | } 303 | else if ($this->exportFile($f, "cache") ){ 304 | $file = $tmpDir . '/' . $f; 305 | } 306 | else{ 307 | $file = null; 308 | } 309 | 310 | if(!is_null($file)){ 311 | if(!file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT), LOCK_EX)){ 312 | $this->msg .= "
" . (Template::getLanguage() == 'de' ? "Konnte Datei nicht schreiben" : "Error writing") . ": " . $f; 313 | $ok &= false; 314 | } 315 | } 316 | } 317 | 318 | // import cache 319 | UnRead::loadFromDisk($tmpDir); 320 | RadioBrowser::loadFromDisk($tmpDir); 321 | 322 | // tidy up tmpDir 323 | $cok = true; 324 | foreach(scandir($tmpDir) as $f){ 325 | if(is_file($tmpDir . '/' . $f)){ 326 | $cok &= unlink($tmpDir . '/' . $f); 327 | } 328 | } 329 | $cok &= rmdir($tmpDir); 330 | if(!$cok){ 331 | $this->msg .= "
" . (Template::getLanguage() == 'de' ? "Aufräumen schlug fehlt!" : "Error during clean up!"); 332 | $ok &= false; 333 | } 334 | 335 | // invalidate caches 336 | foreach(Id::getTableData()["codes"] as $id){ 337 | (new Cache('radios_podcasts.' . $id ))->removeGroup(); 338 | } 339 | (new Cache('table.json'))->removeGroup(); 340 | 341 | return $ok; 342 | } 343 | 344 | private function runAppend(array $export) : bool { 345 | $mainTable = Id::getTableData(); 346 | $idShift = count( $mainTable['ids'] ); 347 | 348 | // change the data to import in a way such that the items are appended 349 | foreach($export as $f => $value){ 350 | if($this->exportFile($f, "list")){ // increment ids of all list files 351 | $newF = preg_replace_callback( 352 | '/^((?:radio|podcast)s_)([0-9]{1,4})(\.json)$/', 353 | fn($m) => $m[1] . intval($m[2])+$idShift . $m[3], 354 | $f 355 | ); 356 | 357 | $export[$newF] = $value; 358 | unset($export[$f]); 359 | } 360 | else if($this->exportFile($f, "cache")){ // increment the keys of the cache files 361 | $export[$f] = array(); 362 | foreach($value as $k => $v){ 363 | if(!empty($v)){ 364 | $export[$f][$k+$idShift] = $v; 365 | } 366 | } 367 | } 368 | else if($this->exportFile($f, "table")){ // create a new "merged" table. 369 | $export[$f] = $mainTable; // set the main table 370 | 371 | // append import to the current main table 372 | foreach($value["ids"] as $k => $v){ 373 | // tamper values to import 374 | $id = $k + $idShift; 375 | $mac = $v[0]; 376 | $code = $v[1]; 377 | 378 | if(isset($export[$f]["macs"][$mac])){ 379 | $this->msg .= "
" . (Template::getLanguage() == 'de' ? "Radio $code war bereits im System vorhanden, Listen wurden überschrieben!" : "Radio $code was already known to system, list have been overwritten!"); 380 | } 381 | 382 | // prevent collisions among GUI-Codes 383 | while( isset( $export[$f]['codes'][$code] ) ){ 384 | $code = 'Z' . Helper::randomCode( 4 ); 385 | } 386 | 387 | // add to table 388 | $export[$f]["macs"][$mac] = $id; 389 | $export[$f]["ids"][$id] = array($mac, $code); 390 | $export[$f]["codes"][$code] = $id; 391 | } 392 | } 393 | } 394 | 395 | // run a replace with changed data (and no clean up of data dir!) 396 | return $this->runReplace($export, false); 397 | 398 | } 399 | 400 | private function runSingle(array $export, string $codeExport, string $codeSystem) : bool { 401 | $mainTable = Id::getTableData(); 402 | 403 | if( !isset($export["table.json"]["codes"][$codeExport]) || !isset($mainTable["codes"][$codeSystem]) ){ 404 | $this->msg = Template::getLanguage() == 'de' ? "Bitte prüfen Sie beide Codes!" : "Please make sure both Codes are available!"; 405 | return false; 406 | } 407 | 408 | $idExport = $export["table.json"]["codes"][$codeExport]; 409 | $idSystem = $mainTable["codes"][$codeSystem]; 410 | 411 | $newExport = array(); 412 | foreach($export as $f => $value){ 413 | if($this->exportFile($f, "list")){ 414 | preg_match('/^((?:radio|podcast)s_)([0-9]{1,4})(\.json)$/', $f, $matches); 415 | $id = intval($matches[2]); 416 | 417 | if($id == $idExport){ // if this is the id to import, make sure to write this file 418 | $newExport[$matches[1] . $idSystem . $matches[3]] = $value; 419 | } 420 | } 421 | else if($this->exportFile($f, "cache")){ 422 | if(!empty($value[$idExport])){ 423 | $newExport[$f] = array( 424 | $idSystem => $value[$idExport] 425 | ); 426 | } 427 | } 428 | // the table stays unchanged 429 | } 430 | 431 | // run a replace with changed data (and no clean up of data dir!) 432 | return $this->runReplace($newExport, false); 433 | } 434 | 435 | } 436 | ?> -------------------------------------------------------------------------------- /php/classes/Inner.php: -------------------------------------------------------------------------------- 1 | data = new Data($id); 24 | $this->radios = $this->data->getRadioList(); 25 | $this->podcasts = $this->data->getPodcastList(); 26 | $this->template = $template; 27 | } 28 | 29 | public function clearCache() : void { 30 | if(Config::USE_LOGO_CACHE){ 31 | $this->template->setContent('CLEAR_CACHE', ''); 32 | 33 | if(isset($_GET['clear-logo-cache'])){ 34 | $this->html[] = 'Cleared logo cache!' : 'red;">Error clearing logo cache!' 36 | ) .''; 37 | } 38 | } 39 | } 40 | 41 | public function checkPost() : void { 42 | if(isset( $_GET['radios'] ) && isset( $_POST['name'] )){ 43 | $this->html[] = 'Changed Radio stations!'; 44 | 45 | $this->radios = array(); 46 | foreach( $_POST['name'] as $id => $name ){ 47 | if( !empty($name) ){ 48 | $this->radios[] = array( 49 | 'name' => self::filterName( $name ), 50 | 'url' => self::filterURL( $_POST['url'][$id] ), 51 | 'logo' => self::filterURL( $_POST['logo'][$id] ), 52 | 'desc' => self::filterName( $_POST['desc'][$id] ), 53 | 'proxy' => isset($_POST['proxy'][$id]) && $_POST['proxy'][$id] == 'yes', 54 | 'type' => !empty($_POST['type'][$id]) && $_POST['type'][$id] == 'nc' ? 'nc' : 'radio', 55 | 'category' => $this->getCategory($id) 56 | ); 57 | } 58 | } 59 | $this->data->setRadioList($this->radios); 60 | } 61 | else if(isset( $_GET['podcasts'] ) && isset( $_POST['name'] ) ){ 62 | $this->html[] = 'Changed podcasts!'; 63 | 64 | $this->podcasts = array(); 65 | foreach( $_POST['name'] as $id => $name ){ 66 | if( !empty($name) ){ 67 | $this->podcasts[] = array( 68 | 'name' => self::filterName( $name ), 69 | 'url' => self::filterURL( $_POST['url'][$id] ), 70 | 'finalurl' => isset($_POST['finalurl'][$id]) && $_POST['finalurl'][$id] == 'yes', 71 | 'proxy' => isset($_POST['proxy'][$id]) && $_POST['proxy'][$id] == 'yes', 72 | 'type' => isset($_POST['type'][$id]) && $_POST['type'][$id] == 'nc' ? 'nc' : 'rss', 73 | 'category' => $this->getCategory($id) 74 | ); 75 | } 76 | } 77 | $this->data->setPodcastList($this->podcasts); 78 | } 79 | } 80 | 81 | public function radioForm() : void { 82 | $categories = array_filter(array_unique(array_column($this->radios, 'category'))); 83 | $radios = array(); 84 | $count = 0; 85 | foreach($this->radios as $key => $radio ){ 86 | $id = ($key+1000); 87 | 88 | $radios[] = array( 89 | "ID" => $id, 90 | "COUNT" => $count, 91 | "NAME" => htmlspecialchars($radio['name'], encoding: 'UTF-8'), 92 | "URL" => $radio['url'], 93 | "PROXY_YES" => $radio['proxy'] ? 'checked="checked"' : '', 94 | "PROXY_NO" => !$radio['proxy'] ? 'checked="checked"' : '', 95 | "TYPE_RADIO" => $radio['type'] != 'nc' ? 'checked="checked"' : '', 96 | "TYPE_NC" => $radio['type'] == 'nc' ? 'checked="checked"' : '', 97 | "LOGO" => $radio['logo'], 98 | "DESC" => htmlspecialchars($radio['desc'], encoding: 'UTF-8'), 99 | "CAT_OPTIONS" => array_reduce( 100 | $categories, 101 | fn($c, $i) => $c.'', 102 | '' 103 | ) 104 | ); 105 | 106 | $count++; 107 | } 108 | $this->template->setMultipleContent('RadioStations', $radios); 109 | $this->template->setContent('RADIO_COUNT', $count); 110 | $this->template->setContent('RADIO_OPTIONS', array_reduce($categories, fn($c, $i) => $c.'', '' )); 111 | } 112 | 113 | public function podcastForm() : void { 114 | $categories = array_filter(array_unique(array_column($this->podcasts, 'category'))); 115 | $podcasts = array(); 116 | $count = 0; 117 | foreach($this->podcasts as $key => $pod ){ 118 | $id = ($key+3000); 119 | 120 | $podcasts[] = array( 121 | "ID" => $id, 122 | "COUNT" => $count, 123 | "NAME" => htmlspecialchars($pod['name'], encoding: 'UTF-8'), 124 | "URL" => $pod['url'], 125 | "TYPE_RSS" => $pod['type'] == 'rss' ? 'checked="checked"' : '', 126 | "TYPE_NC" => $pod['type'] == 'nc' ? 'checked="checked"' : '', 127 | "ENDURL_YES" => $pod['finalurl'] ? 'checked="checked"' : '', 128 | "ENDURL_NO" => !$pod['finalurl'] ? 'checked="checked"' : '', 129 | "PROXY_YES" => $pod['proxy'] ? 'checked="checked"' : '', 130 | "PROXY_NO" => !$pod['proxy'] ? 'checked="checked"' : '', 131 | "CAT_OPTIONS" => array_reduce( 132 | $categories, 133 | fn($c, $i) => $c.'', 134 | '' 135 | ) 136 | ); 137 | 138 | $count++; 139 | } 140 | $this->template->setMultipleContent('Podcasts', $podcasts); 141 | $this->template->setContent('PODCAST_COUNT', $count); 142 | $this->template->setContent('PODCAST_OPTIONS', array_reduce($categories, fn($c, $i) => $c.'', '' )); 143 | } 144 | 145 | public function outputMessages() : void { 146 | $this->template->setContent('ADD_HTML', implode(PHP_EOL, $this->html)); 147 | } 148 | 149 | // ==== // 150 | 151 | public static function filterURL(string $url) : string { 152 | $url = filter_var( $url, FILTER_VALIDATE_URL) ? substr( $url , 0, 1000) : 'invalid'; 153 | return empty($url) ? '' : $url; 154 | } 155 | 156 | public static function filterName(string $name): string{ 157 | // $name = str_replace( ['ä','ü','ß','ö','Ä','Ü','Ö'], ['ae','ue','ss','oe','Ae','Ue','Oe'], $name); 158 | $name = substr( preg_replace( 159 | '/[^ -\x{2122}]/u', 160 | // pattern inspired from 161 | // https://stackoverflow.com/a/43106144 by mickmackusa 162 | // CC BY-SA, https://creativecommons.org/licenses/by-sa/3.0/ 163 | '', $name 164 | ), 0, 200 ); 165 | 166 | return empty($name) ? 'empty' : $name; 167 | } 168 | 169 | public static function filterCategory(string $cat) : string{ 170 | $cat = self::filterName($cat); 171 | $cat = preg_replace( '/[^0-9A-Za-z \-\,]/', '', $cat ); 172 | return empty($cat) ? 'empty' : $cat; 173 | } 174 | 175 | private function getCategory(int $post_id): string { 176 | if($_POST['cat'][$post_id] === '*root') { 177 | return ""; 178 | } 179 | else { 180 | return self::filterCategory( 181 | $_POST['cat'][$post_id] === '*new' ? 182 | $_POST['new_cat'][$post_id] : $_POST['cat'][$post_id] 183 | ); 184 | } 185 | } 186 | } 187 | ?> 188 | -------------------------------------------------------------------------------- /php/classes/JSONCache.php: -------------------------------------------------------------------------------- 1 | file = self::BASE_DIR . '/' . sha1($group) . '.json'; 32 | $data = null; 33 | if(is_file($this->file)){ 34 | $data = json_decode(file_get_contents($this->file), true); 35 | if(is_null($data)){ 36 | rename($this->file, $this->file . '.error'); 37 | } 38 | } 39 | 40 | if(is_null($data)){ 41 | $this->data = array(); 42 | $this->writeFile(); 43 | } 44 | else{ 45 | $this->data = $data; 46 | } 47 | 48 | if(!is_writable($this->file)){ 49 | throw new Exception('Unable to write to cache file "'. $this->file .'"!'); 50 | } 51 | } 52 | 53 | private function writeFile() : bool { 54 | if( !$this->cleanupRan ){ 55 | $oldKeys = array_diff( 56 | array_keys($this->data), 57 | $this->getAllKeysOfGroup() 58 | ); 59 | foreach($oldKeys as $oK){ 60 | unset($this->data[$oK]); 61 | } 62 | $this->cleanupRan = true; 63 | } 64 | return file_put_contents( 65 | $this->file, 66 | json_encode($this->data, JSON_PRETTY_PRINT), 67 | LOCK_EX 68 | ); 69 | } 70 | 71 | public function getAllKeysOfGroup() : array { 72 | return array_values(array_filter( 73 | array_keys($this->data), 74 | fn($k) => $this->keyExists($k) 75 | )); 76 | } 77 | 78 | public function removeGroup() : bool { 79 | $this->data = array(); 80 | return $this->writeFile(); 81 | } 82 | 83 | public function set( string $key, array|string $value, int $ttl = 0 ): bool { 84 | $this->data[$key] = array( 85 | $value, 86 | $ttl === 0 ? true : (time() + $ttl) 87 | ); 88 | return $this->writeFile(); 89 | } 90 | 91 | public function get( string $key ) : string { 92 | return $this->keyExists($key) ? $this->data[$key][0] : ''; 93 | } 94 | 95 | public function keyExists(string $key) : bool { 96 | return isset($this->data[$key]) && ($this->data[$key][1] === true || time() <= $this->data[$key][1]); 97 | } 98 | 99 | public function remove(string $key) : bool { 100 | unset($this->data[$key]); 101 | return $this->writeFile(); 102 | } 103 | 104 | // # # # # # 105 | // Key => Array (HashMap) 106 | // # # # # # 107 | 108 | public function arraySet( string $key, array $array, int $ttl = 0 ) : bool { 109 | $d = array(); 110 | foreach( $array as $k => $v ){ 111 | $d[strval($k)] = $v; 112 | } 113 | return $this->set($key, $d, $ttl); 114 | } 115 | 116 | public function arrayGet( string $key ) : array { 117 | return $this->keyExists($key) ? $this->data[$key][0] : array(); 118 | } 119 | 120 | public function arrayKeyExists(string $key, string $arrayKey ) : bool { 121 | return $this->keyExists($key) && isset($this->data[$key][0][$arrayKey]); 122 | } 123 | 124 | public function arrayKeyGet(string $key, string $arrayKey ) { 125 | return $this->arrayGet($key)[$arrayKey]; 126 | } 127 | 128 | public function arrayKeySet(string $key, ?string $arrayKey, $value, int $ttl = 0 ) : bool { 129 | $d = $this->arrayGet($key); 130 | if( $arrayKey === null ){ 131 | $d[] = $value; 132 | } 133 | else{ 134 | $d[$arrayKey] = $value; 135 | } 136 | return $this->set($key, $d, $ttl); 137 | } 138 | 139 | public function output(): void { 140 | echo '=================================' . PHP_EOL; 141 | foreach($this->getAllKeysOfGroup() as $key){ 142 | echo $key . "\t\t : " . (is_array( $this->data[$key][0]) ? $this->arrayGet($key) : $this->get($key)) . PHP_EOL; 143 | } 144 | echo '=================================' . PHP_EOL . PHP_EOL; 145 | } 146 | } 147 | 148 | ?> -------------------------------------------------------------------------------- /php/classes/Login.php: -------------------------------------------------------------------------------- 1 | isLoggedIn() ){ 25 | $_SESSION['login_time'] = time(); 26 | } 27 | else{ 28 | $_SESSION['login'] = false; 29 | } 30 | } 31 | 32 | public function isLoggedIn() : bool { 33 | return isset($_SESSION['login']) && isset( $_SESSION['db_all'] ) && 34 | $_SESSION['login'] && $_SESSION['login_time'] + 1200 > time(); 35 | } 36 | 37 | public function getId() : int { 38 | return $_SESSION['db_all']['id']; 39 | } 40 | 41 | public function getAll() : array { 42 | return $_SESSION['db_all']; 43 | } 44 | 45 | public function loginByCode(string $code) : void { 46 | try{ 47 | $id = new Id( $code, Id::CODE ); 48 | $_SESSION['login'] = true; 49 | $_SESSION['login_time'] = time(); 50 | $_SESSION['db_all'] = array( 51 | 'mac' => $id->getMac(), 52 | 'id' => $id->getId(), 53 | 'code' => $id->getCode(), 54 | ); 55 | } 56 | catch(Exception $e){ 57 | $_SESSION['login'] = false; 58 | } 59 | } 60 | 61 | public function logout() : void { 62 | $_SESSION['login'] = false; 63 | } 64 | } 65 | ?> -------------------------------------------------------------------------------- /php/classes/M3U.php: -------------------------------------------------------------------------------- 1 | radioid = $id; 24 | $this->data = new Data($this->radioid->getId()); 25 | } 26 | 27 | public function musicStream( $id ) : void { 28 | // id ok? 29 | if( is_numeric( $id ) && preg_replace('/[^0-9]/','', $id ) === $id ){ 30 | // get station 31 | $stat = $this->data->getById($id); 32 | if( !empty($stat) ){ // is a station 33 | if( $stat['type'] == 'nc' ){ // nextcloud stattion? 34 | 35 | $urls = PodcastLoader::getMusicById( $id, $this->data ); 36 | if( $stat['proxy'] ){ 37 | // proxy links 38 | $m3uLinks = array(); 39 | foreach( $urls as $k => $m ){ 40 | $m3uLinks[] = Config::RADIO_DOMAIN . 'stream.php?id=' . $id . '&track=' . $k . '&mac=' . $this->radioid->getMac(); 41 | } 42 | } 43 | else{ // echo links (no proxy) 44 | $m3uLinks = $urls; 45 | } 46 | 47 | if( Config::SHUFFLE_MUSIC ){ // different random order each 10 minutes 48 | srand(intdiv(time(), 600)); 49 | shuffle($m3uLinks); 50 | } 51 | } 52 | else{ // normal station? (just echo streaming-link) 53 | $m3uLinks = array( 54 | $stat['url'] 55 | ); 56 | } 57 | 58 | $this->outputM3U($m3uLinks); 59 | } 60 | } 61 | } 62 | 63 | public function audiobookStream() : void { 64 | die('See Issue #7 on https://github.com/KIMB-technologies/Radio-API/'); 65 | } 66 | 67 | private function outputM3U(array $urls) : void { 68 | header('Content-Type: audio/x-mpegurl; charset=utf-8'); 69 | echo implode( PHP_EOL, $urls ) . PHP_EOL; 70 | die(); 71 | } 72 | } 73 | ?> -------------------------------------------------------------------------------- /php/classes/Output.php: -------------------------------------------------------------------------------- 1 | ['Podcast', 'Podcast'], 35 | 'Radio' => ['Radio stations', 'Radiosender'], 36 | 'Radio-Browser' => ['Radio-Browser', 'Radio-Browser'], 37 | 'Stream' => ['Stream', 'Stream'], 38 | 'GUI-Code' => ['GUI-Code', 'GUI-Code'], 39 | // 40 | 'Countries' => ['Countries', 'Länder'], 41 | 'All States' => ['All States', 'Alle Bundesländer'], 42 | 'Languages' => ['Languages', 'Sprachen'], 43 | 'My Last' => ['My Last', 'Meine zuletzt gehörten'], 44 | 'Tags' => ['Tags', 'Kategorien'], 45 | 'Top Click' => ['Top Click', 'Am meisten gehört'], 46 | 'Top Vote' => ['Top Vote', 'Am höchsten bewertet'], 47 | 'Next Page' => ['Next Page', 'Nächste Seite'], 48 | 'You do not have last stations.' => ['You do not have last stations.', 'Sie haben keine zuletzt gehörten Sender!'] 49 | ); 50 | 51 | /** 52 | * Create Outputter 53 | */ 54 | public function __construct(string $lang = 'eng'){ 55 | $this->language = array_search($lang, self::ALL_LANGUAGES) ?? 0; 56 | $this->logo = new RadioLogo(); 57 | } 58 | 59 | /** 60 | * Add a station 61 | */ 62 | public function addStation( int|string $id, string $name, string $url, 63 | $light = false, string $desc = '', string $logo = '', int|string $sortKey = "") : void { 64 | $a = array( 65 | 'ItemType' => 'Station', 66 | 'StationId' => $id, 67 | 'StationName' => $this->cleanText($name, true), 68 | ); 69 | if( !$light ){ 70 | $b = array( 71 | 'StationUrl' => $this->cleanUrl($url), 72 | 'StationDesc' => $this->cleanText($desc), 73 | 'Logo' => $this->cleanUrl($this->logo->logoUrl($logo)), 74 | 'StationFormat' => 'Radio', 75 | 'StationLocation' => 'Earth', 76 | 'StationBandWidth' => 32, 77 | 'StationMime' => 'MP3', 78 | 'Relia' => 5 79 | ); 80 | } 81 | else{ 82 | $b = array(); 83 | } 84 | $this->items[] = array_merge( $a, $b ); 85 | $this->itemsSortKeys[] = 'ra==' . ($sortKey === "" ? $name : $sortKey); 86 | } 87 | 88 | /** 89 | * Add a podcast 90 | */ 91 | public function addPodcast( int $podcastid, string $name, string $url, int|string $sortKey = "" ) : void { 92 | $this->items[] = array( 93 | 'ItemType' => 'ShowOnDemand', 94 | 'ShowOnDemandID' => $podcastid, 95 | 'ShowOnDemandName' => $this->cleanText($name, true), 96 | 'ShowOnDemandURL' => $this->cleanUrl($url), 97 | 'ShowOnDemandURLBackUp' => $this->cleanUrl($url), 98 | 'BookmarkShow' => '' 99 | ); 100 | $this->itemsSortKeys[] = 'pod==' . ($sortKey === "" ? $name : $sortKey); 101 | } 102 | 103 | /** 104 | * Add a podcast episode 105 | */ 106 | public function addEpisode( int $podcastid, int|null $episodeid, string $podcastname, string $episodename, 107 | string $url, string $desc = '', string $logo = '', bool $top = false ) : void { 108 | $this->items[] = array( 109 | 'ItemType' => 'ShowEpisode', 110 | 'ShowEpisodeID' => $podcastid . (!is_null($episodeid) ? 'X' . $episodeid : ''), 111 | 'ShowName' => $this->cleanText($podcastname, true), 112 | 'Logo' => $this->cleanUrl($this->logo->logoUrl($logo)), 113 | 'ShowEpisodeName' => $this->cleanText($episodename, true), 114 | 'ShowEpisodeURL' => $this->cleanUrl($url), 115 | 'BookmarkShow' => '', 116 | 'ShowDesc' => $this->cleanText($desc), 117 | 'ShowFormat' => 'Podcast', 118 | 'Lang' => 'KIMBisch', 119 | 'Country' => 'KIMB', 120 | 'ShowMime' => 'MP3' 121 | ); 122 | $this->itemsSortKeys[] = ($top ? 'epA' : 'epZ' ) . '==' . $podcastid . '==' . $episodeid; 123 | } 124 | 125 | /** 126 | * Add a folder 127 | */ 128 | public function addDir(string $name, string $url, bool $isLast = false, int|string $sortKey = "") : void { 129 | $this->items[] = array( 130 | 'ItemType' => 'Dir', 131 | 'Title' => $this->cleanText($name, true), 132 | 'UrlDir' => $this->cleanUrl($url), 133 | 'UrlDirBackUp' => $this->cleanUrl($url) 134 | ); 135 | $this->itemsSortKeys[] = ($isLast ? 'z' : '') . 'dir==' . ($sortKey === "" ? $name : $sortKey);; 136 | } 137 | 138 | /** 139 | * Set or override a Previous (<- Back URL) 140 | */ 141 | public function prevUrl(string $url) : void { 142 | $this->prevurl = $this->cleanUrl($url); 143 | } 144 | 145 | private function cleanText( string $s, bool $translate = false ): string { 146 | if($translate){ 147 | $pos = strpos($s, ':'); 148 | $suffix = ''; 149 | if($pos !== false){ 150 | $suffix = substr($s, $pos); 151 | $s = substr($s, 0, $pos); 152 | } 153 | 154 | if(array_key_exists($s, self::TRANSLATIONS)){ 155 | $s = self::TRANSLATIONS[$s][$this->language]; 156 | } 157 | 158 | $s .= $suffix; 159 | } 160 | return mb_substr( mb_convert_encoding(str_replace( str_split('"&<>/'), '', $s ), 'UTF-8', 'UTF-8'), 0, 100 ); 161 | } 162 | 163 | private function cleanUrl( string $s ): string { 164 | $url = mb_convert_encoding(str_replace( str_split('<>'), '', $s ), 'UTF-8', 'UTF-8'); 165 | return 'http' . ( empty($_SERVER['HTTPS']) ? ':' : 's:' ) . substr( $url, strpos( $url, '//') ); 166 | } 167 | 168 | private function applyFavorites() : void { 169 | $favorites = array_map( 'trim', explode(',', Config::FAVORITE_ITEMS)); 170 | foreach($this->items as $id => $item ){ 171 | list($type, $name) = explode( '==', $this->itemsSortKeys[$id] ); 172 | 173 | if( 174 | in_array($name, $favorites) || 175 | (isset($item['Title']) && in_array($item['Title'], $favorites) ) || 176 | (isset($item['StationName']) && in_array($item['StationName'], $favorites) ) || 177 | (isset($item['ShowOnDemandName']) && in_array($item['ShowOnDemandName'], $favorites) ) || 178 | (isset($item['ShowEpisodeName']) && in_array($item['ShowEpisodeName'], $favorites) ) 179 | ){ 180 | $this->itemsSortKeys[$id] = 'A'. $type . '==' . $name; 181 | } 182 | } 183 | } 184 | 185 | /** 186 | * Creates the xml response 187 | * and sends it! 188 | */ 189 | public function __destruct(){ 190 | $this->applyFavorites(); 191 | array_multisort($this->itemsSortKeys, SORT_ASC, SORT_NATURAL|SORT_FLAG_CASE, $this->items); 192 | if( count( $this->items ) > self::MAX_ITEMS ){ 193 | $this->items = array_slice($this->items, 0, self::MAX_ITEMS); 194 | } 195 | //output 196 | $lines = array( 197 | '', 198 | '', 199 | ' ' . ( count( $this->items ) ) .'' 200 | ); 201 | 202 | // add <- back url 203 | if(!empty( $this->prevurl )){ 204 | array_unshift( $this->items, 205 | array( 206 | 'ItemType' => 'Previous', 207 | 'UrlPrevious' => $this->prevurl, 208 | 'UrlPreviousBackUp' => $this->prevurl 209 | ) 210 | ); 211 | } 212 | 213 | foreach( $this->items as $item ){ 214 | $lines[] = ' '; 215 | foreach( $item as $key => $value ){ 216 | $lines[] = ' <' . $key . '>' . $value . ''; 217 | } 218 | $lines[] = ' '; 219 | } 220 | $lines[] = ''; 221 | 222 | //data setup 223 | $out = implode(PHP_EOL, $lines); 224 | 225 | self::sendAnswer($out); 226 | } 227 | 228 | /** 229 | * Sends the given string to the radio, settings all headers and ends script! 230 | */ 231 | public static function sendAnswer(string $out){ 232 | // header setup 233 | header('Content-Type: text/plain;charset=UTF-8'); 234 | header('Expires: Thu, 19 Nov 1981 08:52:00 GMT'); 235 | header('Cache-Control: no-store, no-cache, must-revalidate'); 236 | header('Pragma: no-cache'); 237 | header('Content-Length: ' . strlen( $out )); 238 | 239 | die( $out ); 240 | } 241 | } 242 | 243 | ?> 244 | -------------------------------------------------------------------------------- /php/classes/OwnStreams.php: -------------------------------------------------------------------------------- 1 | redis = new Cache( 'own_stream' ); 26 | } 27 | } 28 | 29 | public function getStreams() : array { 30 | if( !self::IS_ACTIVE ){ 31 | return array(); 32 | } 33 | 34 | if( $this->redis->keyExists( 'list' ) ){ 35 | return $this->redis->arrayGet( 'list' ); 36 | } 37 | 38 | $data = file_get_contents( Config::STREAM_JSON ); 39 | $streams = array(); 40 | 41 | if( !empty($data) ){ 42 | $list = json_decode( $data, true ); 43 | if( !empty( $list ) ){ 44 | 45 | foreach( $list as $item ){ 46 | $urlOK = isset( $item['url'] ) && filter_var($item['url'], FILTER_VALIDATE_URL) !== false; 47 | 48 | $stream = array( 49 | "name" => (!$urlOK ? "(url invalid!)" : '') 50 | . (isset( $item['name'] ) && is_string($item['name']) ? $item["name"] : 'Invalid Name'), 51 | "url" => $urlOK ? $item['url'] : '', 52 | "live" => isset($item['live']) && is_bool($item['live']) ? $item['live'] : true, 53 | "proxy" => isset($item['proxy']) && is_bool($item['proxy']) ? $item['proxy'] : false 54 | ); 55 | // add logo if in json 56 | if(isset($item['logo']) && filter_var($item['logo'], FILTER_VALIDATE_URL) !== false){ 57 | $stream['logo'] = $item['logo']; 58 | } 59 | 60 | $streams[] = $stream; 61 | } 62 | $this->redis->arraySet( 'list', $list, Config::CACHE_EXPIRE ); 63 | } 64 | } 65 | 66 | if(empty($streams)){ 67 | $streams[] = array( 68 | "name" => 'No Stream Items found or error on JSON parse.', 69 | "url" => "", 70 | "live" => true, 71 | "proxy" => false 72 | ); 73 | } 74 | 75 | return $streams; 76 | } 77 | } 78 | 79 | ?> -------------------------------------------------------------------------------- /php/classes/PodcastLoader.php: -------------------------------------------------------------------------------- 1 | / /s/ " 38 | if(preg_match('/^(https?\:\/\/.*)(? array( 48 | 'method' => "PROPFIND", 49 | "header" => "Authorization: Basic " . base64_encode($share . ':') 50 | ) 51 | )); 52 | $data = file_get_contents( $server . '/public.php/webdav/', false, $cont ); 53 | 54 | // parse webdav XML 55 | $data = json_decode(json_encode( 56 | simplexml_load_string( $data, 'SimpleXMLElement', 0, "d", true) 57 | ), true ); 58 | 59 | $poddata = array( 60 | 'title' => '', 61 | 'logo' => '', 62 | 'episodes' => array() 63 | ); 64 | $eid = 1; 65 | 66 | // iterate files 67 | foreach($data["response"] as $r){ 68 | 69 | // get data from xml 70 | $mime = $r["propstat"]["prop"]["getcontenttype"] ?? ''; 71 | $href = $r["href"] ?? ''; 72 | 73 | // get filename 74 | $filename = urldecode(substr( $href, strrpos( $href, '/' ) + 1 )); 75 | // get streaming/ web url 76 | if(Config::LEGACY_NEXTCLOUD){ // old NC share download link (before v31) 77 | $streamurl = $server . ($useindex ? '/index.php' : '') . '/s/'. $share .'/download?path=%2F&files=' . rawurlencode( $filename ); 78 | } 79 | else{ // new NC share download link (starting v31) 80 | $streamurl = $server . '/public.php/dav/files/' . $share . '/' . rawurlencode( $filename ); 81 | } 82 | 83 | // is this an audio file? 84 | if( str_starts_with($mime, 'audio/') ){ 85 | $poddata['episodes'][$eid] = array( 86 | 'title' => $filename, 87 | 'desc' => '', 88 | 'url' => $streamurl 89 | ); 90 | $eid++; 91 | } 92 | // it this a logo? 93 | else if(str_starts_with($mime, 'image/') && str_starts_with($filename, "logo") ){ 94 | $poddata['logo'] = $streamurl; 95 | } 96 | } 97 | 98 | return $poddata; 99 | } 100 | 101 | /** 102 | * RSS/ Atom loader 103 | */ 104 | private static function loadFromFeed( string $url ) : array{ 105 | $rss = file_get_contents( $url ); 106 | $data = json_decode(json_encode( simplexml_load_string( $rss, 'SimpleXMLElement', LIBXML_NOCDATA ) ), true ); 107 | 108 | $poddata = array( 109 | 'title' => $data['channel']['title'] ?? '', 110 | 'logo' => isset( $data['channel']['image'] ) ? $data['channel']['image']['url'] : '', 111 | 'episodes' => array() 112 | ); 113 | 114 | $eid = 1; 115 | 116 | if( isset( $data['channel']['item']['enclosure'] ) ){ // one item is like "item" : { ... }, but should be like "item" : [ { ... } ] 117 | $data['channel']['item'] = array( $data['channel']['item'] ); 118 | } 119 | 120 | foreach( $data['channel']['item'] as $item ){ 121 | if( !empty( $item['enclosure'] ) ){ 122 | 123 | if( count( $item['enclosure'] ) > 1){ 124 | $url = ''; 125 | foreach( $item['enclosure'] as $en ){ 126 | if( substr( $en['@attributes']['type'], 0, 5) == 'audio' ){ 127 | $url = $en['@attributes']['url']; 128 | break; 129 | } 130 | } 131 | if( empty($url) ){ 132 | $url = $item['enclosure'][0]['@attributes']['url']; 133 | } 134 | } 135 | else{ 136 | $url = $item['enclosure']['@attributes']['url']; 137 | } 138 | 139 | $poddata['episodes'][$eid] = array( 140 | 'title' => empty( $item['title'] ) ? 'Unnamed' : $item['title'], 141 | 'desc' => empty( $item['description'] ) ? '' : $item['description'] , 142 | 'url' => $url 143 | ); 144 | $eid++; 145 | } 146 | } 147 | return $poddata; 148 | } 149 | 150 | /** 151 | * Podcast from url loader, using the cache 152 | */ 153 | public static function getPodcastByUrl( string $url, bool $nextcloud ) : array { 154 | self::loadRedis(); 155 | $urlKey = 'url.' . sha1($url); 156 | if( self::$redis->keyExists($urlKey) ){ 157 | return self::$redis->arrayGet($urlKey); 158 | } 159 | 160 | $poddata = $nextcloud ? self::loadFromNextcloud( $url ) : self::loadFromFeed( $url ); 161 | self::$redis->arraySet($urlKey, $poddata, Config::CACHE_EXPIRE ); 162 | return $poddata; 163 | } 164 | 165 | /** 166 | * Get informations about one episode 167 | */ 168 | public static function getEpisodeData( int $id, int $eid, Data $data ) : array{ 169 | $pod = $data->getById( $id ); 170 | if( $pod['tid'] !== 3 ){ 171 | return array(); 172 | } 173 | $poddata = self::getPodcastByUrl( $pod['url'], !empty($pod['type']) && $pod['type'] == 'nc' ); 174 | 175 | if( isset( $poddata['episodes'][$eid] ) ){ 176 | //ok 177 | return array( 178 | 'episode' => $poddata['episodes'][$eid], 179 | 'title' => $poddata['title'], 180 | 'logo' => $poddata['logo'], 181 | 'finalurl' => !empty($pod['finalurl']), 182 | 'proxy' => !empty($pod['proxy']), 183 | 'type' => !empty($pod['type']) && $pod['type'] == 'nc' ? 'nc' : 'rss' 184 | ); 185 | } 186 | else{ 187 | return array(); 188 | } 189 | } 190 | 191 | /** 192 | * Get the Podcast by its ID 193 | */ 194 | public static function getPodcastDataById( int $id, Data $data ) : array { 195 | $pod = $data->getById( $id ); 196 | if( $pod['tid'] !== 3 ){ 197 | return array(); 198 | } 199 | return self::getPodcastByUrl( $pod['url'], !empty($pod['type']) && $pod['type'] == 'nc' ); 200 | } 201 | 202 | /** 203 | * Get nextcloud url list from nextcloud radio station ID 204 | */ 205 | public static function getMusicById( int $id, Data $data ) : array { 206 | $stat = $data->getById($id); 207 | if( !empty($stat) && $stat['type'] == 'nc' ){ // is a nextcloud station 208 | self::loadRedis(); 209 | 210 | $m3Key = 'm3u.'.sha1($stat['url']); 211 | if( self::$redis->keyExists($m3Key) ){ 212 | return json_decode(self::$redis->get($m3Key)); 213 | } 214 | 215 | $music = self::getPodcastByUrl( $stat['url'], true )['episodes']; 216 | $urls = array_values(array_map(fn($i) => $i['url'], $music)); 217 | self::$redis->set( $m3Key, json_encode($urls), Config::CACHE_EXPIRE ); 218 | 219 | return $urls; 220 | } 221 | else{ 222 | return array(); 223 | } 224 | } 225 | } 226 | ?> -------------------------------------------------------------------------------- /php/classes/RadioLogo.php: -------------------------------------------------------------------------------- 1 | useImageCache = Config::USE_LOGO_CACHE && is_writable(self::BASE_DIR); 24 | } 25 | 26 | public function clearCache() : bool { 27 | if($this->useImageCache){ 28 | $ok = true; 29 | foreach(scandir(self::BASE_DIR) as $d){ 30 | if(preg_match('/^[a-f0-9]{40}\.(image|error)$/', $d) === 1){ 31 | $ok &= unlink(self::BASE_DIR . '/' . $d); 32 | } 33 | } 34 | return $ok; 35 | } 36 | return false; 37 | } 38 | 39 | public function logoUrl(string $logo) : string { 40 | // empty or no url 41 | if(empty($logo) || substr($logo, 0, 4) != 'http'){ 42 | return self::BASE_URL . 'default.png'; 43 | } 44 | 45 | // do not use cache 46 | if(!$this->useImageCache){ 47 | return $logo; 48 | } 49 | 50 | // create hash, cache file access 51 | $namehash = sha1($logo); 52 | $cacheurl = Config::RADIO_DOMAIN . 'image.php?hash=' . $namehash; 53 | 54 | // return file from cache if available 55 | if(is_file(self::BASE_DIR . '/' . $namehash . '.image')){ 56 | return $cacheurl; 57 | } 58 | // if download error, return url to logo 59 | else if(is_file(self::BASE_DIR . '/' . $namehash . '.error')){ 60 | return $logo; 61 | } 62 | else { 63 | // try download 64 | if($this->fetchLogo($logo)){ 65 | return $cacheurl; 66 | } 67 | else { 68 | // create error file 69 | file_put_contents(self::BASE_DIR . '/' . $namehash . '.error', ''); 70 | return $logo; 71 | } 72 | } 73 | } 74 | 75 | private function fetchLogo(string $logo) : bool { 76 | //download 77 | $image = file_get_contents($logo); 78 | if($image === false){ 79 | return false; 80 | } 81 | 82 | // file names 83 | $namehash = sha1($logo); 84 | $filename = self::BASE_DIR . '/' . $namehash . '.image'; 85 | // store 86 | file_put_contents($filename, $image); 87 | 88 | // mime 89 | $finfo = finfo_open(FILEINFO_MIME_TYPE); 90 | $mimetype = finfo_file($finfo, $filename); 91 | finfo_close($finfo); 92 | 93 | // its not a image file 94 | if( substr($mimetype, 0, strlen('image/')) !== 'image/'){ 95 | unlink($filename); 96 | return false; 97 | } 98 | 99 | $tmpName = tempnam(sys_get_temp_dir(), 'conv'); 100 | if($mimetype == 'image/svg+xml'){ 101 | if( self::svg2png($filename, $tmpName) ){ 102 | return rename($tmpName, $filename); 103 | } 104 | else { 105 | unlink($filename); 106 | return false; 107 | } 108 | } 109 | else { 110 | 111 | // resize file 112 | // will only return true if resize done (false on error or if already small enough) 113 | if(self::resize($filename, $mimetype, $tmpName)){ 114 | rename($tmpName, $filename); 115 | } 116 | 117 | return true; 118 | } 119 | } 120 | 121 | private static function svg2png(string $inputSVG, string $outputPNG) : bool { 122 | $command = array( 123 | 'rsvg-convert', 124 | '--width', '256', 125 | '--height', '256', 126 | '--keep-aspect-ratio', 127 | '--background-color', 'white', 128 | '--format', 'png', 129 | '-o', '"'.$outputPNG.'"', 130 | '"'.$inputSVG.'"' 131 | ); 132 | exec(implode(' ', $command), result_code:$rs); 133 | return $rs === 0; 134 | } 135 | 136 | private static function imageDimensions(string $file) : array { 137 | $finfo = finfo_open(FILEINFO_CONTINUE); 138 | $info = finfo_file($finfo, $file); 139 | finfo_close($finfo); 140 | 141 | // file info (including dimensions as ", 000 x 000,") 142 | if(preg_match('/,(\d+)x(\d+),/', str_replace(' ', '', $info), $matches) === 1){ 143 | $width = intval($matches[1]); 144 | $height = intval($matches[2]); 145 | 146 | return [$width, $height]; 147 | } 148 | else{ 149 | return [0, 0]; 150 | } 151 | } 152 | 153 | private static function resize(string $inputFile, string $inputMime, string $outputPNG) : bool { 154 | // determine image dimensions 155 | list($width, $height) = self::imageDimensions($inputFile); 156 | 157 | // error 158 | if($width == 0 || $height == 0){ 159 | // no resize 160 | return false; 161 | } 162 | 163 | // do not resize if smaller than 256 px 164 | if( $width <= 256 && $height <= 256 ){ 165 | // resize not necessary 166 | return false; 167 | } 168 | 169 | // create an svg with image 170 | $svgFile = " 172 | 174 | "; 175 | 176 | // write to tmp 177 | $inputSVG = tempnam(sys_get_temp_dir(), 'svg'); 178 | file_put_contents($inputSVG, $svgFile); 179 | 180 | // create small png 181 | return self::svg2png($inputSVG, $outputPNG); 182 | } 183 | 184 | } 185 | 186 | ?> -------------------------------------------------------------------------------- /php/classes/RedisCache.php: -------------------------------------------------------------------------------- 1 | redis = new Redis(); 44 | $this->redis->pconnect(self::$host, self::$port); 45 | if( !empty(self::$auth) ){ 46 | $this->redis->auth(self::$auth); 47 | } 48 | 49 | if( !$this->redis->ping() ){ 50 | throw new Exception('Unable to connect to Redis Server!'); 51 | } 52 | 53 | $this->prefix = base64_encode(hash('sha512', strtolower($group), true)) . ':'; 54 | } 55 | 56 | /** 57 | * Generate the key for some storage. 58 | * @param $key the key 59 | * @return the full key 60 | */ 61 | private function generateKey( string $key ) : string { 62 | return $this->prefix . str_replace( ':', '', $key ); 63 | } 64 | 65 | public function getAllKeysOfGroup(bool $trimPrefix = true) : array { 66 | $all = array(); 67 | $lenpref = strlen($this->prefix); 68 | $iterator = NULL; 69 | do { 70 | $keys = $this->redis->scan($iterator); 71 | if ($keys !== FALSE) { 72 | $all = array_merge( $all, array_filter( $keys, function( $k ) use ($lenpref){ 73 | return substr($k, 0, $lenpref) == $this->prefix; 74 | })); 75 | } 76 | } while ($iterator > 0); 77 | if($trimPrefix){ 78 | $all = array_map(fn($k) => substr($k, $lenpref), $all); 79 | } 80 | return $all; 81 | } 82 | 83 | public function removeGroup() : bool { 84 | $dels = $this->getAllKeysOfGroup(false); 85 | return $this->redis->unlink($dels) == count($dels); 86 | } 87 | 88 | // # # # # # 89 | // Key => Value 90 | // # # # # # 91 | 92 | public function set( string $key, string $value, int $ttl = 0 ): bool { 93 | $r = $this->redis->set( $this->generateKey($key), $value ); 94 | if( $ttl !== 0){ 95 | $this->redis->expire($this->generateKey($key), $ttl); 96 | } 97 | return $r; 98 | } 99 | 100 | public function get( string $key ) : string { 101 | return $this->redis->get($this->generateKey($key)); 102 | } 103 | 104 | public function keyExists(string $key) : bool { 105 | return $this->redis->exists($this->generateKey($key)); 106 | } 107 | 108 | public function remove(string $key) : bool { 109 | return $this->redis->del($this->generateKey($key)) == 1; 110 | } 111 | 112 | // # # # # # 113 | // Key => Array (HashMap) 114 | // # # # # # 115 | 116 | public function arraySet( string $key, array $array, int $ttl = 0 ) : bool { 117 | $this->remove( $key ); 118 | $d = array(); 119 | foreach( $array as $k => $v ){ 120 | $d[strval($k)] = json_encode( $v ); 121 | } 122 | $r = $this->redis->hMSet( $this->generateKey($key), $d); 123 | if( $ttl !== 0){ 124 | $this->redis->expire( $this->generateKey($key), $ttl); 125 | } 126 | return $r; 127 | } 128 | 129 | public function arrayGet( string $key ) : array { 130 | return array_map( function ($v){ 131 | return json_decode($v, true); 132 | }, $this->redis->hGetAll($this->generateKey($key)) 133 | ); 134 | } 135 | 136 | public function arrayKeyExists(string $key, string $arrayKey ) : bool { 137 | return $this->redis->hExists($this->generateKey($key), strval($arrayKey)); 138 | } 139 | 140 | public function arrayKeyGet(string $key, string $arrayKey ) { 141 | return json_decode( $this->redis->hGet($this->generateKey($key), strval($arrayKey)), true); 142 | } 143 | 144 | public function arrayKeySet(string $key, ?string $arrayKey, $value, int $ttl = 0 ) : bool { 145 | if( $arrayKey === null ){ 146 | $arrayKey = $this->redis->hLen($this->generateKey($key)) + 1; 147 | } 148 | $r = $this->redis->hSet( $this->generateKey($key), strval($arrayKey), json_encode($value)); 149 | if( $ttl !== 0){ 150 | $this->redis->expire($this->generateKey($key), $ttl); 151 | } 152 | return $r; 153 | } 154 | 155 | public function output(): void { 156 | echo '=================================' . PHP_EOL; 157 | echo 'Key' . "\t\t : " . 'Value' . PHP_EOL; 158 | echo '---------------------------------' . PHP_EOL; 159 | $lenpref = strlen($this->prefix); 160 | foreach( $this->getAllKeysOfGroup(false) as $fullkey ){ 161 | $key = substr($fullkey, $lenpref); 162 | if( $this->redis->type($fullkey) !== Redis::REDIS_HASH ){ 163 | $val = $this->get($key); 164 | } 165 | else { 166 | $val = json_encode($this->arrayGet($key)); 167 | } 168 | echo $key . "\t\t : " . $val . PHP_EOL; 169 | } 170 | echo '=================================' . PHP_EOL . PHP_EOL; 171 | } 172 | } 173 | 174 | ?> -------------------------------------------------------------------------------- /php/classes/Router.php: -------------------------------------------------------------------------------- 1 | radioid = $id; 30 | $this->out = new Output(isset($_GET['dlang']) && is_string($_GET['dlang']) ? $_GET['dlang'] : 'eng'); 31 | $this->data = new Data($this->radioid->getId()); 32 | $this->unread = new UnRead($this->radioid->getId()); 33 | $this->radio_browser = new RadioBrowser($this->radioid); 34 | } 35 | 36 | /** 37 | * Handle the get requests 38 | */ 39 | public function handleGet(string $uri) : void { 40 | // only one station or episode (play this) 41 | if( isset( $_GET['sSearchtype'] ) && ($_GET['sSearchtype'] == 3 || $_GET['sSearchtype'] == 5 ) && !empty($_GET['Search'])){ 42 | // is an ID from Radio-Browser?? 43 | if( RadioBrowser::matchStationID($_GET['Search']) ){ 44 | $this->radio_browser->handleStationPlay($this->out, $_GET['Search']); 45 | } 46 | // podcast episode ID? "3000-3999"+"X"+"[0-9]+" 47 | else if( preg_match('/^(3\d\d\d)X(\d+)$/', $_GET['Search'], $parts ) === 1 ){ 48 | $this->listPodcastEpisode(intval($parts[1]), intval($parts[2])); 49 | } 50 | // radio or "own stream" ID (Range 1000 - 2999) 51 | else if( preg_match('/^(1|2)\d\d\d$/', $_GET['Search'], $parts ) === 1){ 52 | $this->listPlayItem(intval($_GET['Search']), $parts[1]); 53 | } 54 | else { 55 | $this->out->addDir( 'No item found for this ID!', Config::RADIO_DOMAIN . '?go=initial'); 56 | } 57 | } 58 | // radio browser browsing 59 | else if( $uri == '/radio-browser' && !empty( $_GET['by'] ) && !empty( $_GET["term"] ) ){ 60 | $offset = isset($_GET["offset"]) && is_numeric($_GET["offset"]) ? intval($_GET["offset"]) : 0; 61 | $this->radio_browser->handleBrowse($this->out, $_GET['by'], $_GET["term"], $offset); 62 | } 63 | // (Un)Read for podcast episodes (used in GUI) 64 | else if( !empty($_GET['toggleUnRead']) && is_string($_GET['toggleUnRead']) ){ 65 | $this->out->addDir('TOGGLE-UN-READ-' . $this->unread->toggleById($_GET['toggleUnRead'], $this->data), ''); 66 | } 67 | // list of stations or podcasts (= the type) and possibly selecting a category 68 | else if( $uri == '/list' ){ 69 | $this->out->prevUrl(Config::RADIO_DOMAIN . '?go=initial'); 70 | 71 | if( !empty($_GET['tid']) && is_numeric($_GET['tid']) && array_key_exists($_GET['tid'], $this->data->getTypes()) ){ 72 | $tid = intval($_GET['tid']); 73 | } 74 | else if( empty($_GET['tid']) && !empty($_GET['cat']) ) { 75 | $tid = null; 76 | } 77 | else{ 78 | $this->out->addDir( 'No item found for this tID or Category!', Config::RADIO_DOMAIN . '?go=initial'); 79 | } 80 | 81 | // list items of a podcast 82 | if( $tid == 3 && isset($_GET['id']) && preg_match('/^\d+$/', $_GET['id']) === 1 ){ 83 | $this->listPodcast(intval($_GET['id'])); 84 | } 85 | // list items of type (or all of category), possibly filter by category 86 | else{ 87 | $this->listDirectory( 88 | $tid, 89 | isset($_GET['cat']) && preg_match( '/^[0-9A-Za-z \-\,]{0,200}$/', $_GET['cat'] ) === 1 ? $_GET['cat'] : null 90 | ); 91 | } 92 | } 93 | // list of types (startpage) 94 | else{ 95 | // add local types 96 | foreach( $this->data->getTypes() as $tid => $name ){ 97 | $this->out->addDir( $name, Config::RADIO_DOMAIN . 'list?tid=' . $tid ); 98 | } 99 | // add RadioBrowser 100 | $this->out->addDir( 'Radio-Browser', Config::RADIO_DOMAIN . 'radio-browser?by=none&term=none' ); 101 | 102 | // add code (for gui) 103 | $this->out->addDir( 'GUI-Code: ' . $this->radioid->getCode(), Config::RADIO_DOMAIN . '?go=initial', true ); 104 | 105 | // add Favorites category, if exists 106 | $allCats = $this->data->getCategories(); 107 | if( in_array( 'Favorites', $allCats ) || in_array( 'Favoriten', $allCats ) ) { 108 | $name = in_array( 'Favoriten', $allCats ) ? 'Favoriten' : 'Favorites'; 109 | $this->out->addDir( $name, Config::RADIO_DOMAIN . 'list?cat=' . $name ); 110 | } 111 | 112 | // Log unknown request 113 | if( 114 | preg_match('/^\/setupapp\/[A-Za-z0-9\-\_]+\/asp\/BrowseXML\/loginXML.asp/i', $uri) === 0 && 115 | (!isset( $_GET['go'] ) || $_GET['go'] != "initial") 116 | ){ 117 | file_put_contents( 118 | Config::LOG_DIR . '/requests.log', 119 | date('d.m.Y H:i:s') . " : " . json_encode( $_GET ) . PHP_EOL, 120 | FILE_APPEND 121 | ); 122 | } 123 | } 124 | } 125 | 126 | private function listPodcastEpisode(int $id, int $eid, int $tid = 3) : void { 127 | $this->out->prevUrl(Config::RADIO_DOMAIN . 'list?tid='.$tid.'&id='.$id); 128 | 129 | $ed = PodcastLoader::getEpisodeData( $id, $eid, $this->data ); 130 | if( $ed != array() ){ 131 | $this->unread->openItem($id, $ed['episode']['url']); 132 | 133 | $this->out->addEpisode( 134 | $id, $eid, 135 | $ed['title'], $ed['episode']['title'], 136 | $this->data->getPodcastURL($id, $eid, $this->radioid->getMac()), 137 | $ed['episode']['desc'], 138 | $ed['logo'] 139 | ); 140 | } 141 | } 142 | 143 | private function listPlayItem(int $id, int $tid = 1) : void { 144 | $sta = $this->data->getById( $id ); 145 | 146 | $this->out->prevUrl( 147 | Config::RADIO_DOMAIN . 'list?tid=' . $tid . 148 | (empty($sta['category']) ? '' : '&cat=' . rawurlencode($sta['category'])) 149 | ); 150 | 151 | if( $sta !== array() ){ 152 | // radio station or stream with "live" == true 153 | if( !isset($sta["live"]) || $sta["live"] ){ 154 | $this->out->addStation( 155 | $id, 156 | $sta['name'], 157 | $this->data->getStationURL($id, $this->radioid->getMac()), 158 | false, 159 | $sta['desc'] ?? '', 160 | $sta['logo'] ?? '' 161 | ); 162 | } 163 | else { // "live" == false 164 | $this->out->addEpisode( 165 | $id, null, 166 | $sta['name'], $sta['name'], 167 | $this->data->getStationURL($id, $this->radioid->getMac()), 168 | $sta['desc'] ?? '', 169 | $sta['logo'] ?? '' 170 | ); 171 | } 172 | } 173 | } 174 | 175 | private function listPodcast(int $id) : void { 176 | $this->unread->searchItem($id); 177 | 178 | $pod = $this->data->getById($id); 179 | $this->out->prevUrl( 180 | Config::RADIO_DOMAIN . 'list?tid=3' . 181 | (empty($pod['category']) ? '' : '&cat=' . rawurlencode($pod['category'])) 182 | ); 183 | 184 | $pd = PodcastLoader::getPodcastDataById( $id, $this->data ); 185 | 186 | foreach( $pd['episodes'] as $eid => $e ){ 187 | $this->out->addEpisode( 188 | $id, $eid, 189 | $pd['title'], 190 | $this->unread->knownItemMark($id, $e['url']) . $e['title'], 191 | $this->data->getPodcastURL($id, $eid, $this->radioid->getMac(), sloppy: true), 192 | $e['desc'], $pd['logo'], 193 | !$this->unread->knownItem($id, $e['url']) 194 | ); 195 | } 196 | 197 | } 198 | 199 | private function listDirectory(?int $tid = null, ?string $cat = null) : void { 200 | // first add categories if in "root" folder of type 201 | if(is_null($cat) && !is_null($tid)){ 202 | foreach( $this->data->getCategories( $tid ) as $category ){ 203 | $this->out->addDir( $category, Config::RADIO_DOMAIN . 'list?tid=' . $tid . '&cat=' . rawurlencode($category) ); 204 | } 205 | } 206 | else{ 207 | $this->out->prevUrl(Config::RADIO_DOMAIN . (is_null($tid) ? '?go=initial' : 'list?tid=' . $tid)); 208 | } 209 | 210 | // then add items 211 | foreach( $this->data->getListOfItems( $tid, $cat ) as $id => $item ){ 212 | if($item['tid'] == 1 || ($item['tid'] == 2 && $item['live'])){ // radio station, or live "own stream" 213 | $this->out->addStation( 214 | $id, 215 | $item['name'], 216 | $this->data->getStationURL($id, $this->radioid->getMac()), 217 | true 218 | ); 219 | } 220 | else if($item['tid'] == 3){ // podcast 221 | $this->out->addPodcast( 222 | $id, 223 | $item['name'], 224 | Config::RADIO_DOMAIN . 'list?tid=3&id=' . $id 225 | ); 226 | } 227 | else if ($item['tid'] == 2 && !$item['live']) { // file based "own stream" 228 | $this->out->addEpisode( 229 | $id, null, 230 | $item['name'], $item['name'], 231 | $this->data->getStationURL($id, $this->radioid->getMac()) 232 | ); 233 | } 234 | } 235 | } 236 | } 237 | ?> 238 | -------------------------------------------------------------------------------- /php/classes/SimpleProxy.php: -------------------------------------------------------------------------------- 1 | $value ){ 27 | if( in_array( $key, ['CONTENT_LENGTH', 'CONTENT_TYPE', 'REQUEST_METHOD']) || 28 | null !== $key = preg_filter('/^HTTP_([A-Za-z_]+)$/', '$1', $key ) 29 | ){ 30 | //make CamelCase and replace _ by -, e.g. MY_HEADER to My-Header 31 | $key = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', $key)))); 32 | $headers[$key] = $value . ""; 33 | } 34 | } 35 | return array_diff( $headers, [' ', '']); 36 | } 37 | 38 | private static function createContextByHeaders( $headers, $host = null ){ 39 | // Method 40 | $method = $headers['Request-Method']; 41 | if( !in_array( strtolower( $method ), ['head','get','post'] ) ){ 42 | die('Unsupported HTTP Type'); 43 | } 44 | unset($headers['Request-Method']); 45 | 46 | // Host 47 | if( $host !== null ){ 48 | $headers['Host'] = $host; 49 | } 50 | // Other Header fields 51 | array_walk( $headers, function (&$value, $key) { 52 | $value = $key . ': ' . preg_replace( "/:|\n|\r/", '', $value ); 53 | }); 54 | 55 | //POST 56 | if( strtolower( $method ) == 'post' ){ 57 | $postdata = http_build_query( $_POST ); 58 | } 59 | else{ 60 | $postdata = null; 61 | } 62 | 63 | // Create 64 | $opts = array( 65 | 'http' => array( 66 | 'method' => $method, 67 | 'header' => implode( "\r\n", $headers ) . "\r\n", 68 | 'timeout' => self::$TIMEOUT, 69 | 'content' => $postdata 70 | ) 71 | ); 72 | $ctx = stream_context_create($opts); 73 | stream_context_set_params($ctx, array("notification" => "SimpleProxy::callback")); 74 | return $ctx; 75 | } 76 | 77 | private static function callback( $notification_code, $severity, $message, $message_code, $bytes_transferred, $bytes_max ){ 78 | if( $notification_code === STREAM_NOTIFY_FILE_SIZE_IS ){ 79 | header( 'Content-Length: ' . $bytes_max ); 80 | } 81 | if( $notification_code === STREAM_NOTIFY_MIME_TYPE_IS ){ 82 | header( 'Content-Type: ' . $message ); 83 | } 84 | } 85 | 86 | private static function sendHeader( $header ){ 87 | $nohead = array( 'Content-Length:', 'Content-Type:'); 88 | foreach( $header as $h ){ 89 | $ok = true; 90 | foreach( $nohead as $pref ){ 91 | if( substr( $h, 0, strlen( $pref ) ) == $pref ){ 92 | $ok = false; 93 | break; 94 | } 95 | } 96 | if( $ok ){ 97 | header( preg_replace( "/\n|\r/", '', $h ) ); 98 | } 99 | } 100 | } 101 | 102 | public static function open( $url ){ 103 | $host = preg_filter( '/^https?:\/\/([^\/\:]+).*$/', '$1', $url ); 104 | $f = fopen( 105 | $url, 106 | 'rb', 107 | false, 108 | self::createContextByHeaders( 109 | self::getAllRequestHeaders(), 110 | $host 111 | ) 112 | ); 113 | $header = $http_response_header; 114 | 115 | if( $f !== false ){ 116 | self::sendHeader($header); 117 | // unset the execution limit (long time proxying) 118 | set_time_limit(0); 119 | // while something to proxy and still a client 120 | while(!feof($f) && !connection_aborted() ){ 121 | echo fread($f, 128); 122 | flush(); 123 | }; 124 | fpassthru($f); 125 | fclose($f); 126 | } 127 | else{ 128 | die('Connection Error'); 129 | } 130 | } 131 | } 132 | 133 | ?> -------------------------------------------------------------------------------- /php/classes/Template.php: -------------------------------------------------------------------------------- 1 | .html and .json 17 | * The JSON defines all Placeholders used in the Template and the default values. 18 | * Templates can be included in each other, while the content of the inner goes to: 19 | * %%INNERCONTAINER%% 20 | */ 21 | class Template{ 22 | /* 23 | Using this Template system, you must not allow users to insert strings like (wile xxx is some alphanum.) 24 | , , %%xxxx%% 25 | */ 26 | 27 | /** 28 | * Name, Placeholderdata and included Template 29 | */ 30 | private $filename = ''; 31 | private $placeholder = array(); 32 | private $multiples = array(); 33 | private $multiples_data = array(); 34 | private $inner = null; 35 | 36 | private static $lang = 'en'; 37 | private static $allLangs = array( 38 | 'de', 39 | 'en' 40 | ); 41 | 42 | /** 43 | * Change the language of the site 44 | * see $allLangs for list 45 | * @param lang the lang to use 46 | */ 47 | public static function setLanguage( string $lang ) : void { 48 | if( in_array( $lang, self::$allLangs ) ){ 49 | self::$lang = $lang; 50 | } 51 | } 52 | 53 | /** 54 | * Get the language of the site 55 | * @return string the lang used 56 | */ 57 | public static function getLanguage() : string { 58 | return self::$lang; 59 | } 60 | 61 | /** 62 | * Detect the language sent by the browser (HTTP ACCEPT_LANGUAGE) 63 | * will change to first language the template supports, else stay at default 64 | */ 65 | public static function detectLanguage() : void { 66 | if( !empty( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ){ 67 | 68 | $langs = array_map( function ($i){ 69 | return strtolower(substr(trim($i),0,2)); 70 | }, explode( ',', $_SERVER['HTTP_ACCEPT_LANGUAGE'])); 71 | 72 | foreach($langs as $lang){ 73 | if( in_array($lang, self::$allLangs, true ) ){ 74 | self::setLanguage($lang); 75 | break; 76 | } 77 | } 78 | } 79 | } 80 | 81 | /** 82 | * Create an new Template 83 | * @param name The name of the template 84 | * ./templates/.json) 85 | * ./templates/_.html 86 | */ 87 | public function __construct( $name ){ 88 | if( Helper::checkFileName( $name ) ) { 89 | $this->filename = $name; 90 | if( !is_file( __DIR__ . '/templates/' . $this->filename . '_' . self::$lang . '.html' ) ){ 91 | throw new Exception('Kann Template nicht finden!'); 92 | } 93 | try{ 94 | $this->placeholder = json_decode( file_get_contents( __DIR__ . '/templates/' . $this->filename . '.json' ) , true); 95 | if( isset($this->placeholder['multiples']) ){ 96 | $this->multiples = $this->placeholder['multiples']; 97 | unset($this->placeholder['multiples']); 98 | } 99 | } catch (Exception $e) { 100 | throw new Exception('Kann Template nicht erstellen!'); 101 | } 102 | } 103 | else{ 104 | throw new Exception('Name des Templates fehlerhaft!'); 105 | } 106 | } 107 | 108 | /** 109 | * Sets the content for one type of multiple page elements 110 | * @param $name the name of the multiple page element 111 | * @param $content the content for each part as array 112 | * array( 113 | * array( 114 | * "key" => "val", 115 | * //... 116 | * ) 117 | * //... 118 | * ) 119 | */ 120 | public function setMultipleContent($name, $content) : bool { 121 | if( isset( $this->multiples[$name] ) ){ 122 | $mults = array(); 123 | foreach( $content as $data){ 124 | $mul = $this->multiples[$name]; 125 | foreach( $data as $key => $val){ 126 | $key = "%%".str_replace("%%", "", $key)."%%"; 127 | if( isset( $mul[$key] ) ){ 128 | $mul[$key] = $val; 129 | } 130 | } 131 | $mults[] = $mul; 132 | } 133 | if( !empty($mults) ){ 134 | $this->multiples_data[$name] = $mults; 135 | } 136 | return true; 137 | } 138 | else{ 139 | return false; 140 | } 141 | } 142 | 143 | /** 144 | * Setting the content for one of the placeholders 145 | * @param $key placeholder 146 | * @param $value html value 147 | */ 148 | public function setContent(string $key, string $value) : bool { 149 | $key = "%%".str_replace("%%", "", $key)."%%"; 150 | if( isset( $this->placeholder[$key] ) ){ 151 | $this->placeholder[$key] = $value; 152 | return true; 153 | } 154 | else{ 155 | return false; 156 | } 157 | } 158 | 159 | /** 160 | * Includes a Tempalte in this. (Output of included on will be 161 | * put in %%INNERCONTAINER%%) 162 | * @param $template the template object to include 163 | */ 164 | public function includeTemplate( Template $template ) : bool { 165 | if( get_class( $template ) === 'Template' ){ 166 | $this->inner = $template; 167 | return true; 168 | } 169 | return false; 170 | } 171 | 172 | /** 173 | * Change the loaded Template file 174 | * (only the html is changed, uses the first json) 175 | */ 176 | public function loadOtherTemplate(string $name) : void { 177 | if( Helper::checkFileName( $name ) ) { 178 | if( is_file( __DIR__ . '/templates/' . $name . '_' . self::$lang . '.html' ) ){ 179 | $this->filename = $name; 180 | } 181 | else { 182 | throw new Exception('Kann Template nicht finden!'); 183 | } 184 | } 185 | else{ 186 | throw new Exception('Name des Templates fehlerhaft!'); 187 | } 188 | } 189 | 190 | /** 191 | * Getting the output of this template (incl. included ones) 192 | */ 193 | public function getOutputString() : string { 194 | $htmldata = file_get_contents( __DIR__ . '/templates/' . $this->filename . '_' . self::$lang . '.html' ); 195 | 196 | foreach( $this->multiples as $name => $val ){ 197 | $a = explode( '', $htmldata ); 198 | $b = explode( '', $htmldata ); 199 | 200 | if( !empty($this->multiples_data[$name]) ){ 201 | $inner = substr( $a[1], 0, strpos($a[1], '') ); 202 | $middle = ''; 203 | foreach( $this->multiples_data[$name] as $data){ 204 | $middle .= str_replace( 205 | array_keys( $data ), 206 | array_values( $data ), 207 | $inner ); 208 | } 209 | } 210 | else{ 211 | $middle = ''; 212 | } 213 | $htmldata = $a[0] . $middle . $b[1]; 214 | } 215 | 216 | $this->placeholder['%%SERVERURL%%'] = 'http'. ( empty($_SERVER['HTTPS']) ? '' : 's' ) .':'. 217 | substr(Config::DOMAIN, strpos(Config::DOMAIN, '//')); 218 | $this->placeholder['%%VERSION%%'] = Config::VERSION; 219 | 220 | if( $this->inner !== null ){ 221 | $this->placeholder['%%INNERCONTAINER%%'] = $this->inner->getOutputString(); 222 | } 223 | return str_replace( 224 | array_keys( $this->placeholder ), 225 | array_values( $this->placeholder ), 226 | $htmldata ); 227 | } 228 | 229 | /** 230 | * Output the page using this template. 231 | * ends the script! 232 | */ 233 | public function output(){ 234 | if( !headers_sent() ){ 235 | header( 'Content-type:text/html; charset=utf-8' ); 236 | } 237 | echo $this->getOutputString(); 238 | die(); 239 | } 240 | } 241 | 242 | ?> -------------------------------------------------------------------------------- /php/classes/UnRead.php: -------------------------------------------------------------------------------- 1 | redis = new Cache('unread_podcasts.' . $id ); 28 | } 29 | 30 | /** 31 | * Tell the system, that a user visits the episode list of this podcast 32 | * @param id the podcast id 33 | */ 34 | public function searchItem(int $id, string $noturl = '') : void { 35 | if( $this->redis->keyExists( $id . '-started' ) ){ 36 | $url = $this->redis->get( $id . '-started' ); 37 | if( empty($noturl) || $url !== $noturl ){ 38 | $this->redis->remove( $url ); 39 | $this->redis->remove( $id . '-started' ); 40 | if( $this->redis->keyExists( 'started' ) ){ 41 | $this->redis->remove( 'started' ); 42 | } 43 | $this->dontRemove = true; 44 | } 45 | } 46 | } 47 | 48 | public function knownItem(int $id, string $url) : bool { 49 | return $this->redis->keyExists( $url ); 50 | } 51 | 52 | /** 53 | * Get the status of one episode (as gui prefix string) 54 | * @param id the podcast id 55 | * @param url of episode 56 | */ 57 | public function knownItemMark(int $id, string $url) : string { 58 | return $this->knownItem($id, $url) ? '' : '*'; 59 | } 60 | 61 | /** 62 | * Tell the system, that a user started an episode (SearchType=5) 63 | * @param id the podcast id 64 | * @param eid the episode id 65 | */ 66 | public function openItem(int $id, string $url){ 67 | $this->searchItem($id, $url); 68 | if( !$this->redis->keyExists( $url ) ){ 69 | $this->redis->set( $url, 'S' ); // Started 70 | $this->redis->set( $id . '-started' , $url, 120 ); 71 | $this->redis->set( 'started' , $id , 115 ); 72 | 73 | $this->dontRemove = true; 74 | } 75 | } 76 | 77 | public function __destruct(){ 78 | if( !$this->dontRemove && $this->redis->keyExists( 'started' ) ){ 79 | $id = intval($this->redis->get( 'started' )); 80 | $this->searchItem($id); 81 | } 82 | } 83 | 84 | /** 85 | * Dump all known podcast episodes to disk (called by cron) 86 | */ 87 | public static function dumpToDisk(?string $exportDir = null) : bool { 88 | if( is_file( __DIR__ . '/../data/table.json' ) ){ 89 | $table = json_decode(file_get_contents( __DIR__ . '/../data/table.json' ), true); 90 | 91 | $reads = array(); 92 | foreach( $table['ids'] as $id => $data ){ 93 | $redis = new Cache('unread_podcasts.' . $id ); 94 | $reads[$id] = array(); 95 | foreach($redis->getAllKeysOfGroup() as $key ){ 96 | // redis removes ":", thus keys are "http//" instead of "https://" 97 | $reads[$id][] = preg_replace('/^(https?):?(\/\/.*)$/', '$1:$2', $key); 98 | } 99 | } 100 | 101 | return file_put_contents( 102 | (is_null($exportDir) ? __DIR__ . '/../data' : $exportDir) . '/unread.json', 103 | json_encode( $reads, JSON_PRETTY_PRINT) 104 | ) !== false; 105 | } 106 | return true; 107 | } 108 | 109 | /** 110 | * Load dumped known episodes into Redis (done on container startup) 111 | */ 112 | public static function loadFromDisk(?string $exportDir = null) : array { 113 | $file = (is_null($exportDir) ? __DIR__ . '/../data' : $exportDir) . '/unread.json'; 114 | if( is_file($file) ){ 115 | $reads = json_decode(file_get_contents($file), true); 116 | foreach( $reads as $id => $read ){ 117 | if( !empty($read) ){ 118 | $redis = new Cache('unread_podcasts.' . $id ); 119 | foreach( $read as $r ){ 120 | // update "old" key! "3001-http://..." => "http://..." 121 | if( preg_match('/^\d+\-(.*)$/', $r, $matches) === 1 ){ 122 | $r = $matches[1]; 123 | } 124 | $redis->set( $r, 'S' ); // Started 125 | } 126 | } 127 | } 128 | return $reads; 129 | } 130 | return array(); 131 | } 132 | 133 | public function toggleById(string $id, Data $data) : string { 134 | $this->dontRemove = true; 135 | 136 | if( preg_match('/^(\d+)X(\d+)$/', $id, $parts ) === 1 ){ 137 | $ed = PodcastLoader::getEpisodeData( $parts[1], $parts[2], $data ); 138 | if( $ed != array() ){ 139 | $rkey = $ed['episode']['url']; 140 | if( $this->redis->keyExists( $rkey ) ){ 141 | $this->redis->remove( $rkey ); 142 | } 143 | else{ 144 | $this->redis->set( $rkey , 'S' ); 145 | } 146 | 147 | if( $this->redis->keyExists( $parts[1] . '-started' ) ){ 148 | $this->redis->remove( $parts[1] . '-started' ); 149 | } 150 | if( $this->redis->keyExists( 'started' ) ){ 151 | $this->redis->remove( 'started' ); 152 | } 153 | return "ok"; 154 | } 155 | } 156 | return "error"; 157 | } 158 | } 159 | ?> 160 | -------------------------------------------------------------------------------- /php/classes/autoload.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /php/classes/templates/imexport.json: -------------------------------------------------------------------------------- 1 | { 2 | "%%FEATUREDISABLED%%" : "display: none;", 3 | "%%FEATUREENABLED%%" : "display: none;", 4 | "%%EXAMPLETOKEN%%" : "undefined", 5 | "%%MESSAGEENABLE%%" : "display: none;", 6 | "%%MESSAGE%%" : "" 7 | } -------------------------------------------------------------------------------- /php/classes/templates/imexport_de.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |

4 | Der Import und Export ist für diese Installation deaktiviert! 5 | Bitte setzen Sie in der Konfiguration den Wert CONF_IM_EXPORT_TOKEN (ohne _ am Anfang) auf einen möglichst zufälligen Token mit mindestens 15 Zeichen aus der Menge A-Z, a-z und 0-9. 6 | Damit wird der Import und Export aktiviert und ist hier mittels des konfigurierten Tokens möglich. 7 |

8 |

9 | Nutzen Sie z.B. den Token %%EXAMPLETOKEN%%. 10 |

11 |
12 |
13 |
14 | %%MESSAGE%% 15 |
16 | 17 |

Export

18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
Token
31 |
32 | 33 |
34 |

35 | Ein Export ist eine Export-Datei (*.json). 36 | Es werden immer alle bekannten Radios dieser Installation von Radio-API exportiert, d..h, für jedes Radio die Liste der Radiosender, Podcasts, zuletzt per RadioBrowser gehörten Station und bereist gehörten Podcast-Episoden. 37 | Auch wird die Liste zur Zuordnung von GUI-Codes zu Radios exportiert. 38 |

39 |

40 | Sie können den Export auch automatisieren und so z.B. regelmäßige Backups zu erstellen. 41 | Der Aufruf von %%SERVERURL%%gui/im-export.php?task=export&token=<token> gibt eine Export-Datei zurück. 42 |

43 |
44 | 45 |

Import

46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 |
Export-Datei
Token
Art 61 | Hinzufügen 62 | Einzelnes Radio überschreiben 63 | Alles ersetzen 64 |
83 |
84 | 85 |
86 | Bitte wählen Sie eine Export-Datei (*.json) für den Import und geben Sie den Token zur Authentifizierung an. 87 | Sie können auswählen, ob Sie die Inhalte aus dem Export beim Import hinzufügen, nur ein einzelnes Radio überschreiben oder alles ersetzen wollen. 88 | 89 |
90 |
Hinzufügen
91 |
92 | Alle Radios und Elemente aus dem Export werden dieser Installation von Radio-API hinzugefügt. 93 | Es werden also weitere Radios für diesen Server eingerichtet. 94 | Stellen Sie sicher, dass die Radios (deren Macs) sich vorher noch nicht mit diesem Server verbunden haben. 95 |
96 |
Einzelnes Radio überschreiben
97 |
98 | Überschreiben Sie die Inhalte eines einzelnen Radios aus dem Export mit einem einzelnen Radio in dieser Installation von Radio-API. 99 | Geben Sie dazu den GUI-Code des Radios im Export an (was sie importieren wollen) und den GUI-Codes des Radios im System/ Installation an (in das sie importieren wollen). 100 | Sie überschreiben damit alle Daten des einen Radios in dieser Installation von Radio-API. 101 |
102 |
Alles ersetzen
103 |
104 | Ersetzen Sie die Inhalte dieser Installation von Radio-API vollständig mit den Inhalten des Exports. 105 | Es werden alle Daten überschrieben! 106 |
107 |
108 | 109 | Starten Sie den Import schließlich über den Button Import starten, fehlerhaften Felder werden rot markiert. 110 |
111 |
112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /php/classes/templates/imexport_en.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |

5 | The import and export is disabled for this installation! 6 | Please set a value for CONF_IM_EXPORT_TOKEN (without leading _) in the configuration. 7 | It should be a random token with at least 15 characters of A-Z, a-z and 0-9. 8 | This will enable import and export and it will be then possible here using the token. 9 |

10 |

11 | For example use the token %%EXAMPLETOKEN%%. 12 |

13 |
14 |
15 |
16 | %%MESSAGE%% 17 |
18 | 19 |

Export

20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
Token
33 |
34 | 35 |
36 |

37 | An export is an export file (*.json). 38 | All known radios of this installation of Radio-API are always exported, i.e., for each radio the list of radio stations, podcasts, stations last listened to via RadioBrowser, and podcast episodes already listened to. 39 | The list for assigning GUI-Codes to radios is also exported. 40 |

41 |

42 | You can also automate the export and thus create regular backups. 43 | The call to %%SERVERURL%%gui/im-export.php?task=export&token=<token> returns an export file. 44 |

45 |
46 | 47 |

Import

48 |
49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 |
Export file
Token
Kind 63 | Append 64 | Overwrite single radio 65 | Replace everything 66 |
85 |
86 | 87 |
88 | Please select an export file (*.json) for the import and enter the token for authentication. 89 | You can choose whether you want to append the content from the export during import, overwrite only a single radio or replace everything. 90 | 91 |
92 |
Append
93 |
94 | All radios and elements from the export are added to this installation of Radio-API. 95 | This means that additional radios will be set up for this server. 96 | Make sure that the radios (their Macs) have not yet connected to this server. 97 |
98 |
Overwrite single radio
99 |
100 | Overwrite the contents of a single radio from the export with a single radio in this installation of Radio-API. 101 | To do this, enter the GUI-Code of the radio in the export (what you want to import) and the GUI code of the radio in the system/installation (into which you want to import). 102 | This will overwrite all data of one radio in this installation of Radio-API. 103 |
104 |
Replace everything
105 |
106 | Replace the contents of this installation of Radio-API completely with the contents of the export. 107 | All data will be overwritten! 108 |
109 |
110 | 111 | Finally, start the import by clicking the Start import button; incorrect fields are highlighted in red. 112 |
113 |
114 | 115 | 116 | -------------------------------------------------------------------------------- /php/classes/templates/list.json: -------------------------------------------------------------------------------- 1 | { 2 | "%%ADD_HTML%%" : "", 3 | "%%CLEAR_CACHE%%" : " display: none; ", 4 | "%%RADIO_MAC%%" : "", 5 | "%%RADIO_DOMAIN%%" : "", 6 | "%%LOGIN_CODE%%" : "", 7 | "%%RADIO_COUNT%%" : "", 8 | "%%PODCAST_COUNT%%" : "", 9 | "%%RADIO_OPTIONS%%" : "", 10 | "%%PODCAST_OPTIONS%%" : "", 11 | "multiples" : { 12 | "RadioStations" : { 13 | "%%ID%%" : "", 14 | "%%COUNT%%" : "", 15 | "%%NAME%%" : "", 16 | "%%URL%%" : "", 17 | "%%PROXY_YES%%" : "", 18 | "%%PROXY_NO%%" : "", 19 | "%%TYPE_RADIO%%" : "", 20 | "%%TYPE_NC%%" : "", 21 | "%%LOGO%%" : "", 22 | "%%DESC%%" : "", 23 | "%%CAT_OPTIONS%%" : "" 24 | }, 25 | "Podcasts" : { 26 | "%%ID%%" : "", 27 | "%%COUNT%%" : "", 28 | "%%NAME%%" : "", 29 | "%%URL%%" : "", 30 | "%%TYPE_RSS%%" : "", 31 | "%%TYPE_NC%%" : "", 32 | "%%ENDURL_YES%%" : "", 33 | "%%ENDURL_NO%%" : "", 34 | "%%PROXY_YES%%" : "", 35 | "%%PROXY_NO%%" : "", 36 | "%%CAT_OPTIONS%%" : "" 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /php/classes/templates/list_de.html: -------------------------------------------------------------------------------- 1 | %%ADD_HTML%% 2 | 3 |
4 | Eingeloggt als %%LOGIN_CODE%%, 5 | Logout 6 |
7 | 8 |

Radiosender

9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 39 | 40 | 41 | 42 | 43 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 94 | 95 | 96 | 97 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 121 | 122 | 123 | 124 | 125 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 152 | 153 | 154 | 155 | 156 | 157 | 158 |
ID
%%ID%%
Name
URL
Typ 36 | Radio 37 | Nextcloud 38 |
Proxy 44 | ✓ 45 | ✗ 46 |
Logo
Beschreibung
Kategorie 62 | 67 | 69 |
Neu
Name
Einfügen aus 91 | über RadioBrowser
92 | mittels RadioBrowser 93 |
RadioBrowser 98 | Zuletzt gehört 99 | Suche 100 |
101 |
102 | 103 | 104 |
105 |
106 |
107 |
URL
Typ 118 | Radio 119 | Nextcloud 120 |
Proxy 126 | ✓ 127 | ✗ 128 |
Logo
Beschreibung
Kategorie 144 | 149 | 151 |
159 |
160 | 161 |
162 | Der Name eines Radiosenders wird im Display des Radios angezeigt, als URL wird ein streamfähiger Link benötigt (MP3, M3U, etc.). 163 | Der Link muss ohne SSL (also http://) erreichbar sein! 164 |
165 | Mit dem Proxy können Links, die nur per SSL erreichbar sind, hinzugefügt werden. 166 | Achtung, der Proxy kann keine M3U Dateien streamen! 167 |
168 | Bei Nextcloud Streams werden alle Musikdateien in einem freigegebenen Ordner vom Radio abgespielt. 169 | Anders als bei Podcasts werden alle Dateien im Ordner nacheinander abgespielt und sind nicht einzeln wählbar. 170 | (Die Nextcloud Freigabe muss die Form <url>/s/<token> und kein Passwort haben.) 171 | Sollte die Nextcloud nur per SSL erreichbar sein, muss der Proxy genutzt werden! 172 | Ist die zufällige Reihenfolge im System (Radio-API Installation) aktiviert, so werden die Dateien im Ordner in einer zufälligen Reihenfolge abgespielt. 173 |
174 | Über Logo kann eine URL zu einem den Sender illustrieredenen Bild angegeben werden. 175 | Wird kein Bild angegeben, so wird %%SERVERURL%%media/default.png im Radio angezeigt. 176 |
177 | Die Beschreibung ist optional und eine weitere Beschreibung des Senders. 178 |
179 |
180 | Es können Elemente (Podcasts oder Radiosender) zu einer Kategorie mit dem Namen Favoriten oder Favorites hinzugefügt werden. 181 | Diese Kategorie wird dann als erstes Element auf oberster Ebene im Display des Radios angezeigt, sodass die Favoriten schnell und direkt erreichbar sind. 182 |
183 | 184 |

Podcasts

185 |
186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 215 | 216 | 217 | 218 | 219 | 223 | 224 | 225 | 226 | 227 | 231 | 232 | 233 | 234 | 235 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 273 | 274 | 275 | 276 | 277 | 281 | 282 | 283 | 284 | 285 | 289 | 290 | 291 | 292 | 293 | 302 | 303 | 304 | 305 | 306 | 307 | 308 |
ID
%%ID%%
Name
URL
Typ 212 | RSS/ Atom 213 | Nextcloud 214 |
EndURL 220 | ✓ 221 | ✗ 222 |
Proxy 228 | ✓ 229 | ✗ 230 |
Kategorie 236 | 241 | 243 |
Neu
Name
URL
Typ 270 | RSS/ Atom 271 | Nextcloud 272 |
EndURL 278 | ✓ 279 | ✗ 280 |
Proxy 286 | ✓ 287 | ✗ 288 |
Kategorie 294 | 299 | 301 |
309 |
310 | 311 |
312 | Der Name eines Podcasts wird in im Display des Radios angezeigt, als URL kann entweder ein RSS-Atom-Feed angegeben werden oder der Link zu einer Nextcloud Freigabe. 313 | Anders als bei Radiosendern werden alle Dateien im Ordner als Episoden angezeigt und sind einzeln abspielbar. 314 | (Die Nextcloud Freigabe muss die Form <url>/s/<token> und kein Passwort haben.) 315 |
316 | Sollte die Nextcloud oder die Audiodateien der Episoden nur per SSL erreichbar sein, so muss der Proxy aktiviert werden! 317 | Der Proxy nutzt automatisch EndURL. 318 |
319 | Einige Anbieter, z.B. podigee oder Soundcloud, leiten in den Links zu den Episoden auf SSL um, die eigentliche URL zur Audiodatei ist jedoch auch ohne SSL erreichbar. 320 | Mit EndURL wird der direkte Link zur Audiodatei an das Radio geschickt. 321 | EndURL ist dem Proxy vorzuziehen, da es den Server weniger belastet und schneller ist. 322 |
323 | 324 | 332 | 333 |

Vorschau

334 |
335 | Logo-Cache leeren 336 |
337 | 338 |
339 |
340 |
341 |
342 | 343 |
344 | Achtung, die Vorschau dient in erster Linie dazu die Liste der Radios, Podcast, Episoden und Streams anzuschauen. 345 | Das Abspielen von einigen Audioformaten klappt in Browsern nicht, dafür aber auf dem Radio. 346 | Weiterhin kann das Radio kein SSL ein Browser jedoch schon. 347 |
348 | 349 | -------------------------------------------------------------------------------- /php/classes/templates/list_en.html: -------------------------------------------------------------------------------- 1 | %%ADD_HTML%% 2 | 3 |
4 | Logged in as %%LOGIN_CODE%%, 5 | Logout 6 |
7 | 8 |

Radio stations

9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 39 | 40 | 41 | 42 | 43 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 94 | 95 | 96 | 97 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 121 | 122 | 123 | 124 | 125 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 152 | 153 | 154 | 155 | 156 | 157 | 158 |
ID
%%ID%%
Name
URL
Type 36 | Radio 37 | Nextcloud 38 |
Proxy 44 | ✓ 45 | ✗ 46 |
Logo
Description
Category 62 | 67 | 69 |
New
Name
Insert from 91 | using RadioBrowser
92 | in RadioBrowser 93 |
RadioBrowser 98 | Last listened 99 | Search 100 |
101 |
102 | 103 | 104 |
105 |
106 |
107 |
URL
Type 118 | Radio 119 | Nextcloud 120 |
Proxy 126 | ✓ 127 | ✗ 128 |
Logo
Description
Category 144 | 149 | 151 |
159 |
160 | 161 |
162 | The name of a radio station will be shown in the radios menu. 163 | The url a has to be a link to a streamable audiofile (MP3, M3U, etc.). 164 | As the radio does not support SSL, thus the links must support http://! 165 |
166 | If there is no link without SSL, one can use the proxy to provide a stream without SSL for the radio. 167 | Attention, the proxy is not able to open M3U streams. 168 |
169 | You can also give a nextcloud file share. 170 | The URL must look like <url>/s/<token> and must not have a password. 171 | Each file in the shared folder will be played one after one (in a random order, if enabled for this installation). 172 | There is no support for sub folders. 173 | If the Nextcloud share is only accessible via SSL, the proxy has to be used. 174 |
175 | While playing a station the radio shows an image, the image can be specified by giving a custom link (also no SSL support). 176 | If no image is specified %%SERVERURL%%media/default.png will be displayed. 177 |
178 | The description holds more information about a station. 179 |
180 | 181 |
182 | You may add items (podcasts or radio stations) to a category called Favorites or Favoriten. 183 | This category will be shown as first item on the top level of the Radio display, s.t., the items in favorites are easily reachable. 184 |
185 | 186 |

Podcasts

187 |
188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 217 | 218 | 219 | 220 | 221 | 225 | 226 | 227 | 228 | 229 | 233 | 234 | 235 | 236 | 237 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 275 | 276 | 277 | 278 | 279 | 283 | 284 | 285 | 286 | 287 | 291 | 292 | 293 | 294 | 295 | 304 | 305 | 306 | 307 | 308 | 309 | 310 |
ID
%%ID%%
Name
URL
Type 214 | RSS/ Atom 215 | Nextcloud 216 |
EndURL 222 | ✓ 223 | ✗ 224 |
Proxy 230 | ✓ 231 | ✗ 232 |
Category 238 | 243 | 245 |
New
Name
URL
Type 272 | RSS/ Atom 273 | Nextcloud 274 |
EndURL 280 | ✓ 281 | ✗ 282 |
Proxy 288 | ✓ 289 | ✗ 290 |
Category 296 | 301 | 303 |
311 |
312 | 313 |
314 | The name of a podcast will be shown in the radios menu. 315 | As url a RSS atom feed or a Nextcloud file share can be provided. 316 | The Nextcloud file share must look like <url>/s/<token> and must not have a password. 317 | Each file in the shared folder will be shown as playable episode. 318 | There is no support for sub folders. 319 |
320 | If the Nextcloud share or the audiofiles of each episode are only accessible via SSL, the proxy has to be used. 321 | It will provide a stream without SSL for the radio. 322 | The proxy will always use EndURL. 323 |
324 | Some podcast provider, e.g., podigee or Soundcloud, redirect their episode URLs to SSL, while they also work without SSL. 325 | The radio won't be able to play these SSL URLs. 326 | Using EndURL the server will determine the redirected destination and send this destination URL to the radio. 327 | 328 | Don't use the proxy if the media can played without proxy or if EndURL is enough. 329 | The proxy will put more load on the server and will slow down the connection time to streams. 330 | 331 |
332 | 333 | 341 | 342 |

Preview

343 |
344 | Clear logo cache 345 |
346 | 347 |
348 |
349 |
350 |
351 | 352 |
353 | Attention, the preview is just a preview for the list of stations, podcasts, and streams. 354 | If some stream or episode does not start playing in the browser, it may do so using the radio nevertheless. 355 | Also if something works using a browser, it may not work with the radio. 356 | One possible reason is, that the radio does not support SSL. 357 |
358 | 359 | -------------------------------------------------------------------------------- /php/classes/templates/login.json: -------------------------------------------------------------------------------- 1 | { 2 | "%%ADD_HTML%%" : "" 3 | } -------------------------------------------------------------------------------- /php/classes/templates/login_de.html: -------------------------------------------------------------------------------- 1 | %%ADD_HTML%% 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
Code
14 |
15 | 16 |
17 | Der Code wird im Menü des Radios angezeigt (Internetradio → Senderliste und dort als GUI-Code). 18 | Jedes Radio muss zuerst einmalig die Senderliste aufgerufen und einen Code angzeigt haben, bevor ein Login hier möglich ist. 19 | Jedes Radio hat eine eigene Senderliste und eine eigene Liste an Podcasts. 20 | Die Liste der Streams ist für den Server fest vorgegeben. 21 |
-------------------------------------------------------------------------------- /php/classes/templates/login_en.html: -------------------------------------------------------------------------------- 1 | %%ADD_HTML%% 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
Code
14 |
15 | 16 |
17 | The code used for the login will be shown in the radio (Internet radio → Stations list and there as GUI-Code) 18 | The Login will become possible, after the radio has shown the code on its display for the first time. 19 | The list of radio stations and podcasts is different for each radio. 20 | The list of streams is defined per installation. 21 |
-------------------------------------------------------------------------------- /php/classes/templates/main.json: -------------------------------------------------------------------------------- 1 | { 2 | "%%MOREHEADER%%" : "", 3 | "%%TITLE%%": "", 4 | "%%INNERCONTAINER%%" : "", 5 | "%%UPDATEINFO%%" : "style=\"display:none;\"" 6 | } -------------------------------------------------------------------------------- /php/classes/templates/main_de.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Radio API – %%TITLE%% 8 | 9 | 10 | 11 | %%MOREHEADER%% 12 | 13 | 14 |
15 |

Radio API – Weboberfläche

16 |
17 | Ein Update für Radio-API ist hier ↗ verfügbar! 18 | Sie haben Version %%VERSION%%. 19 |
20 |

%%TITLE%%

21 | 22 | %%INNERCONTAINER%% 23 |
24 | 35 | 36 | -------------------------------------------------------------------------------- /php/classes/templates/main_en.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Radio API – %%TITLE%% 8 | 9 | 10 | 11 | %%MOREHEADER%% 12 | 13 | 14 |
15 |

Radio API – Web Interface

16 |
17 | An update for Radio-API is available here ↗! 18 | Your current version is %%VERSION%%. 19 |
20 |

%%TITLE%%

21 | 22 | %%INNERCONTAINER%% 23 |
24 | 35 | 36 | -------------------------------------------------------------------------------- /php/classes/templates/view.json: -------------------------------------------------------------------------------- 1 | { 2 | "%%NOTESTYLE%%" : "display: none;", 3 | "%%RADIO_DOMAIN%%" : "" 4 | } -------------------------------------------------------------------------------- /php/classes/templates/view_de.html: -------------------------------------------------------------------------------- 1 | 2 |

3 | Diese Seite zeigt eine Vorschau der Liste im Radio, es wird das Radio angezeigt, als das man zuletzt in der GUI eingeloggt war. 4 |

5 | 6 |
7 |
8 | 9 |
10 |
11 | 12 | 13 | 14 |

Hinweise

15 | 16 |
17 | Sie wurden zur Vorschau umgeleitet.
18 | Suchen Sie die Weboberfläche?
19 | Oder alternativ die API? 20 |
21 | 22 |

 

23 | 24 |
25 | Achtung, die Vorschau dient in erster Linie dazu die Liste der Radios, Podcast, Episoden und Streams anzuschauen. 26 | Das Abspielen von einigen Audioformaten klappt in Browsern nicht, dafür aber auf dem Radio. 27 | Weiterhin kann das Radio kein SSL ein Browser jedoch schon. 28 |
29 | 30 | -------------------------------------------------------------------------------- /php/classes/templates/view_en.html: -------------------------------------------------------------------------------- 1 | 2 |

3 | This page shows a preview of the list shown in the radio. 4 | It uses the login last used with the web interface in this browser. 5 |

6 | 7 |
8 |
9 | 10 |
11 |
12 | 13 | 14 | 15 |

Notes

16 | 17 |
18 | You have been redirected to the preview.
19 | Looking for the Web Interface instead?
20 | Or looking for the API instead? 21 |
22 | 23 |

 

24 | 25 |
26 | Attention, the preview is just a preview for the list of stations, podcasts, and streams. 27 | If some stream or episode does not start playing in the browser, it may do so using the radio nevertheless. 28 | Also if something works using a browser, it may not work with the radio. 29 | One possible reason is, that the radio does not support SSL. 30 |
31 | -------------------------------------------------------------------------------- /php/data/env.json: -------------------------------------------------------------------------------- 1 | { 2 | "_comment-1" : "This file includes the config-values for the non-Docker mode.", 3 | "_comment-2" : "The parameters are identical to the ones when using Docker.", 4 | "_comment-3" : "The non-Docker modes replaces Redis with a file based caching.", 5 | "CONF_DOMAIN" : "http://radio.example.com/", 6 | "____CONF_RADIO_DOMAIN" : "http://hama.wifiradiofrontier.com", 7 | "CONF_ALLOWED_DOMAIN" : "all", 8 | "CONF_SHUFFLE_MUSIC" : "true", 9 | "CONF_CACHE_EXPIRE" : "1200", 10 | "CONF_STREAM_JSON" : "false", 11 | "____CONF_LOG_DIR" : "/my/log/folder", 12 | "____CONF_CACHE_DIR" : "/my/cache/folder/radio-cache", 13 | "____CONF_IM_EXPORT_TOKEN" : "aToken", 14 | "____CONF_USE_LOGO_CACHE" : "true", 15 | "____CONF_FAVORITE_ITEMS" : "Radio,Radio-Browser", 16 | "____CONF_LEGACY_NEXTCLOUD" : "false", 17 | "_comment-4" : "Remove the ____ prefix for using optional config-values." 18 | } -------------------------------------------------------------------------------- /php/data/podcasts_1.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /php/data/radios_1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "NDR Info", 4 | "url": "http:\/\/www.ndr.de\/resources\/metadaten\/audio\/m3u\/ndrinfo_sh.m3u", 5 | "logo": "empty", 6 | "desc": "News", 7 | "proxy": false, 8 | "category": "Nachrichten" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /php/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KIMB-technologies/Radio-API/449acbd4147e3762a7a7d28a6cb2ecd7eb80529e/php/favicon.ico -------------------------------------------------------------------------------- /php/gui/category.js: -------------------------------------------------------------------------------- 1 | $(()=> { 2 | $("select.cat_select").change( (e) =>{ 3 | var option = $(e.target).val(); 4 | var d_id = $(e.target).attr('delid'); 5 | 6 | if(option == "*new"){ 7 | $("input.new_cat[delid='"+d_id+"']").css("display", "block"); 8 | } 9 | else { 10 | $("input.new_cat[delid='"+d_id+"']").css("display", "none"); 11 | $("input.new_cat[delid='"+d_id+"']").val(""); 12 | } 13 | }); 14 | }); -------------------------------------------------------------------------------- /php/gui/im-export.js: -------------------------------------------------------------------------------- 1 | $(() => { 2 | 3 | var err_format = { 4 | 'color' : 'red', 5 | 'font-weight' : 'bold' 6 | }; 7 | var def_format = { 8 | 'color' : 'black', 9 | 'font-weight' : 'normal' 10 | }; 11 | 12 | $("input[name=kind]").change( () => { 13 | var kind = $("input[name=kind]:checked").val(); 14 | if (kind == "replace" ){ 15 | $("tr#replace-confirm-row").css('display', 'table-row'); 16 | $(".kind-single-only").css('display', 'none'); 17 | 18 | $("input[name=replace]").prop('checked', false); 19 | } 20 | else if( kind == "single" ){ 21 | $(".kind-single-only").css('display', 'table-row'); 22 | $("tr#replace-confirm-row").css('display', 'none'); 23 | } 24 | else{ 25 | $("tr#replace-confirm-row").css('display', 'none'); 26 | $(".kind-single-only").css('display', 'none'); 27 | } 28 | }); 29 | 30 | $("form#import").submit( () => { 31 | var kind = $("input[name=kind]:checked").val(); 32 | 33 | // checks 34 | var okF = $("input[name=export]").val().length > 0; 35 | $("tr#file-row").css(okF ? def_format : err_format); 36 | 37 | var okT = $("tr#import-token-row input[name=token]").val().length >= 15; 38 | $("tr#import-token-row").css(okT ? def_format : err_format); 39 | 40 | var okB = kind !== "single" || $("input[name=code-backup]").val().match(/^Z[0-9A-Za-z]{4}$/) !== null; 41 | $("tr#single-backup-row").css(okB ? def_format : err_format); 42 | 43 | var okS = kind !== "single" || $("input[name=code-system]").val().match(/^Z[0-9A-Za-z]{4}$/) !== null; 44 | $("tr#single-system-row").css(okS ? def_format : err_format); 45 | 46 | var okC = kind !== "replace" || $("input[name=replace]").prop('checked'); 47 | $("tr#replace-confirm-row").css(okC ? def_format : err_format); 48 | 49 | return okF && okT && okB && okS && okC; 50 | }); 51 | 52 | $("form#export").submit( () => { 53 | var okT = $("tr#export-token-row input[name=token]").val().length >= 15; 54 | $("tr#export-token-row").css(okT ? def_format : err_format); 55 | 56 | return okT; 57 | }); 58 | 59 | }); -------------------------------------------------------------------------------- /php/gui/im-export.php: -------------------------------------------------------------------------------- 1 | setContent('TITLE', 'Import & Export'); 25 | $mainTemplate->setContent('MOREHEADER', ''); 26 | if(Config::updateAvailable()){ 27 | $mainTemplate->setContent('UPDATEINFO', ''); 28 | } 29 | 30 | // Im- & Export feature activated? 31 | if( Config::IM_EXPORT_TOKEN ){ 32 | if(!empty($_REQUEST['token']) && $_REQUEST['token'] === Config::IM_EXPORT_TOKEN){ 33 | $imExTemplate->setContent('MESSAGEENABLE', ''); 34 | 35 | // run the im- or export 36 | $ie = new ImExport(); 37 | 38 | if($_REQUEST['task'] === 'import'){ 39 | $ok = true; 40 | 41 | if(empty($_FILES["export"]["tmp_name"])){ 42 | $ok = false; 43 | $msg = Template::getLanguage() == 'de' ? 'Keine Export-Datei!' : 'No export file!'; 44 | } 45 | 46 | if( 47 | !empty($_POST["kind"]) && $_POST["kind"] === "replace" && 48 | ( empty($_POST["replace"]) || $_POST["replace"] !== "yes" ) 49 | ){ 50 | $ok = false; 51 | $msg = Template::getLanguage() == 'de' ? 'Bestätigung alles zu überschreiben fehlt!' : 'Confirmation to overwrite all is missing!'; 52 | } 53 | 54 | if($ok){ 55 | $ok = $ie->import( 56 | $_FILES["export"]["tmp_name"], 57 | !empty($_POST["kind"]) && is_string($_POST["kind"]) ? $_POST["kind"] : "error", 58 | !empty($_POST["code-backup"]) && is_string($_POST["code-backup"]) ? $_POST["code-backup"] : null, 59 | !empty($_POST["code-system"]) && is_string($_POST["code-system"]) ? $_POST["code-system"] : null 60 | ); 61 | $msg = $ie->getMsg(); 62 | } 63 | 64 | $msg = '

' . ($ok ? 65 | (Template::getLanguage() == 'de' ? 'Erfolg' : 'Success' ) 66 | : 67 | (Template::getLanguage() == 'de' ? 'Fehler' : 'Error' ) 68 | ) . '

' . $msg; 69 | 70 | } 71 | else if($_REQUEST['task'] === 'export'){ 72 | $ie->export(); 73 | die(); 74 | } 75 | else{ 76 | $msg = Template::getLanguage() == 'de' ? 'Kein oder unbekannter Task!' : 'No or unknown task!'; 77 | } 78 | $imExTemplate->setContent('MESSAGE', $msg); 79 | } 80 | 81 | $imExTemplate->setContent('FEATUREENABLED', ''); 82 | } 83 | else{ 84 | $imExTemplate->setContent('FEATUREDISABLED', ''); 85 | $imExTemplate->setContent('EXAMPLETOKEN', Helper::randomCode(20)); 86 | } 87 | 88 | // add im-export to main and output 89 | $mainTemplate->includeTemplate($imExTemplate); 90 | echo $mainTemplate->output(); 91 | ?> -------------------------------------------------------------------------------- /php/gui/index.php: -------------------------------------------------------------------------------- 1 | setContent('UPDATEINFO', ''); 26 | } 27 | 28 | 29 | // Login Form? 30 | if( isset($_GET['login']) || isset( $_GET['err'] )){ 31 | if( !empty($_POST['code']) && is_string($_POST['code']) ){ 32 | $login->loginByCode($_POST['code']); 33 | } 34 | else{ 35 | $login->logout(); 36 | } 37 | } 38 | if( $login->isLoggedIn() ){ 39 | // RadioBrowser Search? 40 | if(!empty($_GET["search"]) || isset($_GET["last"])){ 41 | $rb = new RadioBrowser($login->getId()); 42 | header('Content-Type: application/json;charset=UTF-8'); 43 | die(json_encode( 44 | isset($_GET["last"]) ? $response = $rb->lastStations() : $rb->searchStation($_GET["search"]), 45 | JSON_PRETTY_PRINT 46 | )); 47 | } 48 | else { 49 | $mainTemplate->setContent('TITLE', Template::getLanguage() == 'de' ? 'Eigene Listen' : 'User defined Lists'); 50 | $mainTemplate->setContent( 'MOREHEADER', 51 | ''. 52 | ''. 53 | '' 54 | ); 55 | 56 | $listTemplate = new Template('list'); 57 | $listTemplate->setContent('RADIO_MAC', $login->getAll()['mac']); 58 | $listTemplate->setContent('LOGIN_CODE', $login->getAll()['code']); 59 | $listTemplate->setContent('RADIO_DOMAIN', Config::RADIO_DOMAIN); 60 | 61 | $mainTemplate->includeTemplate( $listTemplate ); 62 | 63 | $inner = new Inner($login->getId(), $listTemplate); 64 | $inner->checkPost(); 65 | $inner->clearCache(); 66 | 67 | $inner->radioForm(); 68 | $inner->podcastForm(); 69 | 70 | $inner->outputMessages(); 71 | } 72 | } 73 | else{ 74 | $mainTemplate->setContent('TITLE', 'Login'); 75 | $loginTemplate = new Template('login'); 76 | $mainTemplate->includeTemplate( $loginTemplate ); 77 | 78 | // Login Error 79 | if( !empty($_POST['code']) ){ 80 | $msg = Template::getLanguage() == 'de' ? 'Login fehlgeschlagen' : 'Login not successful!'; 81 | $loginTemplate->setContent('ADD_HTML', '
'.$msg.'
'); 82 | } 83 | // Error Page 84 | if( isset( $_GET['err'] ) && ( $_GET['err'] == '404' || $_GET['err'] == '403' ) ){ 85 | $msg = Template::getLanguage() == 'de' ? 'Fehler' : 'Error'; 86 | $loginTemplate->setContent('ADD_HTML', '
'.$msg.' '. $_GET['err'] .'
'); 87 | } 88 | } 89 | echo $mainTemplate->output(); 90 | ?> 91 | -------------------------------------------------------------------------------- /php/gui/radio-browser.js: -------------------------------------------------------------------------------- 1 | $(function (){ 2 | $("input#openLast").click(() => { 3 | radioBrowserDo(false); 4 | }) 5 | $("input#openSearch").click(() => { 6 | radioBrowserDo(true); 7 | }); 8 | 9 | $("input[name=radioBrowserType]").change( () => { 10 | radioBrowserDo( $("input[name=radioBrowserType][value=search]").prop('checked') ) 11 | }); 12 | 13 | $("input#runSearch").click( (e) => { 14 | e.preventDefault(); 15 | runSearchRequest(); 16 | }); 17 | 18 | $("input#searchTerm").keypress( (e) => { 19 | if (e.keyCode === 13) { 20 | e.preventDefault(); 21 | runSearchRequest(); 22 | } 23 | }); 24 | }); 25 | 26 | function radioBrowserDo(isSearch){ 27 | $("#radioBrowserButtons").css("display", "none"); 28 | $("#radioBrowserView").css("display", "table-row"); 29 | 30 | if(isSearch){ 31 | $("input[name=radioBrowserType][value=search]").prop('checked', true); 32 | $("div#searchElements").css("display", "block"); 33 | 34 | $("div#results").text(""); 35 | runSearchRequest(); 36 | } 37 | else{ 38 | $("input[name=radioBrowserType][value=last]").prop('checked', true); 39 | $("div#searchElements").css("display", "none"); 40 | 41 | runRequest("") 42 | } 43 | } 44 | 45 | function runSearchRequest(){ 46 | var term = $("input#searchTerm").val().trim() 47 | if(term.length > 0){ 48 | runRequest(term); 49 | } 50 | } 51 | 52 | var currently_shown_data_list = []; 53 | function choseData(){ 54 | var id = $(this).attr("did"); 55 | var item = currently_shown_data_list[id]; 56 | 57 | $("input[name='name["+radiocount+"]']").val(item.name); 58 | $("input[name='url["+radiocount+"]']").val(item.url); 59 | $("input[name='desc["+radiocount+"]']").val(item.hasOwnProperty('desc') ? item.desc : ''); 60 | $("input[name='logo["+radiocount+"]']").val(item.hasOwnProperty('logo') ? item.logo : ''); 61 | } 62 | 63 | function runRequest(term){ 64 | var isSearch = term.length > 0 65 | 66 | $("div#results").html("Loading ..."); 67 | $.get( 68 | serverurl + 'gui/?' + ( 69 | isSearch ? 'search=' + encodeURIComponent(term) : 'last' 70 | ), 71 | (response) => { 72 | 73 | if( !isSearch ){ 74 | var data = []; 75 | Object.keys(response).forEach( (k) => { 76 | data.push(response[k]) 77 | }); 78 | data.sort((a, b) => b['time'] - a['time']) 79 | } 80 | else{ 81 | var data = response; 82 | } 83 | 84 | var html = "
"; 85 | if(data.length == 0){ 86 | html += "
Nothing found!" 87 | } 88 | data.forEach( (v, k) => { 89 | html += '
'+ v.name +'
'; 90 | if(v.hasOwnProperty('desc')){ 91 | html += '
'+ v.desc +'
'; 92 | } 93 | }) 94 | $("div#results").html(html + "
"); 95 | $("dt.radioBrowserChoose").click(choseData); 96 | currently_shown_data_list = data; 97 | } 98 | ); 99 | } -------------------------------------------------------------------------------- /php/gui/style.css: -------------------------------------------------------------------------------- 1 | body{ 2 | font-family:Ubuntu,sans-serif; 3 | font-size:100%; 4 | background-color:#777; 5 | } 6 | h1{ 7 | color:black; 8 | border-bottom: 1px solid #5d7; 9 | text-align:center; 10 | font-size:2em; 11 | font-type: bold; 12 | } 13 | h2{ 14 | color:black; 15 | border-bottom: 1px solid #5d7; 16 | text-align:left; 17 | font-size:1.5em; 18 | font-type: bold; 19 | } 20 | input[type=text]{ 21 | width:95%; 22 | max-width:500px; 23 | } 24 | 25 | dt { 26 | font-weight: bold; 27 | margin-top: 10px; 28 | } 29 | dd { 30 | font-weight: normal; 31 | font-style: italic; 32 | } 33 | 34 | div#main{ 35 | border: 1px solid black; 36 | margin:auto; 37 | max-width: 800px; 38 | background-color: white; 39 | padding: 5px; 40 | border-radius: 4px; 41 | } 42 | div#footer{ 43 | color: white; 44 | } 45 | div#footer a, div#footer a:visited, div#footer a:hover { 46 | text-decoration: none; 47 | color: white; 48 | } 49 | 50 | div#apiviewer a{ 51 | text-decoration: none; 52 | } 53 | #radioBrowserView { 54 | display: none; 55 | } 56 | #radioBrowserButtons td:last-child, #radioBrowserView td:last-child { 57 | background-color: lightblue; 58 | padding: 20px 5px; 59 | border: 2px solid darkblue; 60 | border-radius: 4px; 61 | } 62 | div.achtung { 63 | border: 2px solid red; 64 | padding: 5px; 65 | border-radius: 4px; 66 | background-color: orange; 67 | margin:auto; 68 | max-width: 750px; 69 | } 70 | div.note { 71 | border: 2px solid darkblue; 72 | padding: 5px; 73 | border-radius: 4px; 74 | background-color: lightblue; 75 | margin:auto; 76 | max-width: 750px; 77 | } -------------------------------------------------------------------------------- /php/gui/view.php: -------------------------------------------------------------------------------- 1 | setContent('TITLE', Template::getLanguage() == 'de' ? 'Vorschau' : 'Preview'); 27 | $mainTemplate->setContent('MOREHEADER', ''); 28 | if(Config::updateAvailable()){ 29 | $mainTemplate->setContent('UPDATEINFO', ''); 30 | } 31 | 32 | $viewTemplate->setContent('RADIO_DOMAIN', Config::RADIO_DOMAIN); 33 | 34 | // Redirect from /index.php to viewer? 35 | if( isset( $_GET['redirFromIndex'] ) ){ 36 | $viewTemplate->setContent('NOTESTYLE', ''); 37 | } 38 | 39 | // add viewer to main and output 40 | $mainTemplate->includeTemplate($viewTemplate); 41 | echo $mainTemplate->output(); 42 | ?> -------------------------------------------------------------------------------- /php/gui/viewer.js: -------------------------------------------------------------------------------- 1 | $(function (){ 2 | if(radiomac === null){ // => using the viewer as a standalone app 3 | if( localStorage.hasOwnProperty("last_radio_mac") ){ 4 | radiomac = localStorage.getItem("last_radio_mac"); 5 | } 6 | else{ 7 | $("div#apiviewer").html("Unable to detect the last used radio in GUI!
Please log into GUI!"); 8 | $("div#apiviewer").addClass("achtung"); 9 | } 10 | } 11 | else{ // => using the list below gui 12 | localStorage.setItem("last_radio_mac", radiomac); 13 | } 14 | 15 | if(radiomac !== null){ 16 | loadPage( serverurl + '?go=initial', 'div#apiviewer' ); 17 | } 18 | }); 19 | 20 | var reloadPageValues = {}; 21 | function reloadPage(){ 22 | loadPage(reloadPageValues.url, reloadPageValues.elem, reloadPageValues.play); 23 | } 24 | 25 | function loadPage( url, elem, play ){ 26 | play = play || false; 27 | reloadPageValues = {url: url, elem:elem, play:play}; 28 | 29 | var html = "
    "; 30 | $.get( url + '&mac=' + radiomac + '&dlang=' + dlang_val , (data) => { 31 | var xml = $( $.parseXML( data.replace(/&/g, '&') ) ); 32 | 33 | xml.find('Item').each( (k,v) => { 34 | html += '
  • ' + printItem(v, play) + "
  • "; 35 | }); 36 | 37 | html += "
"; 38 | $(elem).html( html ); 39 | 40 | addOpenTypeListener(elem); 41 | }); 42 | } 43 | 44 | function printItem( item, play ){ 45 | var type = $(item).find('ItemType').text(); 46 | var playh = ''; 47 | var markAsKnow = ''; 48 | 49 | if( type == "Station" ){ 50 | if(play){ 51 | playh += $(item).find('StationName').text(); 52 | playFile( $(item).find('StationUrl').text(), $(item).find('Logo').text() ); 53 | } 54 | else{ 55 | var name = $(item).find('StationName').text(); 56 | var url = $(item).find('StationId').text(); 57 | } 58 | } 59 | else if( type == "Dir" ){ 60 | var name = $(item).find('Title').text() + ' →'; 61 | var url = $(item).find('UrlDir').text(); 62 | } 63 | else if( type == "Previous" ){ 64 | var name = '← ..'; 65 | var url = $(item).find('UrlPrevious').text(); 66 | } 67 | else if( type == "ShowOnDemand" ){ 68 | var name = $(item).find('ShowOnDemandName').text() + ' →'; 69 | var url = $(item).find('ShowOnDemandURL').text(); 70 | } 71 | else if( type == "ShowEpisode" ){ 72 | if( play ){ 73 | playh += $(item).find('ShowEpisodeName').text(); 74 | playFile( $(item).find('ShowEpisodeURL').text(), $(item).find('Logo').text() ); 75 | } 76 | else{ 77 | var name = $(item).find('ShowEpisodeName').text(); 78 | var url = $(item).find('ShowEpisodeID').text(); 79 | 80 | if( url.match(/^3\d\d\dX\d+$/) ){ // only podcast episodes support UnRead 81 | markAsKnow = ' – ' + (name.substr(0,1) == '*' ? 82 | '' : 83 | ''); 84 | } 85 | } 86 | } 87 | 88 | if( url.startsWith(radiourl)){ 89 | url = serverurl + url.slice(radiourl.length) 90 | } 91 | 92 | return playh == '' ? ''+ name +'' + markAsKnow : playh; 93 | } 94 | 95 | function addOpenTypeListener(elem){ 96 | $("a.openType").click( function (e) { 97 | e.preventDefault(); 98 | var url = $(this).attr('href'); 99 | var type = $(this).attr('opentype'); 100 | 101 | if( type == "Dir" || type == "Previous" || type == "ShowOnDemand" ){ 102 | loadPage( url, elem ); 103 | } 104 | else if( type == "Station" ){ 105 | loadPage( serverurl + '?sSearchtype=3&Search=' + url, elem, true ); 106 | } 107 | else if( type == "ShowEpisode" ){ 108 | loadPage( serverurl + '?sSearchtype=5&Search=' + url, elem, true ); 109 | } 110 | }); 111 | $("span.mark-known").click( function (){ 112 | var url = $(this).parent().children('a').attr('href'); 113 | $(this).html('↻'); 114 | $.get(serverurl + "?mac=" + radiomac + "&toggleUnRead=" + url, (d) => { 115 | if( d.indexOf('TOGGLE-UN-READ-ok') !== -1 ){ 116 | reloadPage(); 117 | } 118 | else { 119 | $(this).html('ERROR'); 120 | } 121 | }); 122 | }); 123 | } 124 | 125 | function playFile( url, logo ) { 126 | if( url.startsWith(radiourl)){ 127 | url = serverurl + url.slice(radiourl.length) 128 | } 129 | if( logo.startsWith(radiourl)){ 130 | logo = serverurl + logo.slice(radiourl.length) 131 | } 132 | 133 | var html ='' 136 | + ''; 137 | $("div#audiodiv").html(html); 138 | } 139 | -------------------------------------------------------------------------------- /php/image.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /php/index.php: -------------------------------------------------------------------------------- 1 | 3a3f5ac48a1dab4e'); 31 | die(); //will never be reached 32 | } 33 | 34 | /** 35 | * Check if IP valid 36 | */ 37 | Config::checkAccess( !empty($_GET['mac']) && Helper::checkValue( $_GET['mac'], Id::MAC_PREG ) ? $_GET['mac'] : null ); 38 | 39 | /** 40 | * Auth 41 | */ 42 | $radioid = Auth::authFromMac(true); 43 | 44 | /** 45 | * Handle 46 | */ 47 | $router = new Router($radioid); 48 | $router->handleGet($uri); 49 | ?> -------------------------------------------------------------------------------- /php/m3u.php: -------------------------------------------------------------------------------- 1 | musicStream($id); 34 | } 35 | else{ 36 | Output::sendAnswer('Invalid Parameter'); 37 | } 38 | ?> -------------------------------------------------------------------------------- /php/media/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KIMB-technologies/Radio-API/449acbd4147e3762a7a7d28a6cb2ecd7eb80529e/php/media/default.png -------------------------------------------------------------------------------- /php/media/metal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KIMB-technologies/Radio-API/449acbd4147e3762a7a7d28a6cb2ecd7eb80529e/php/media/metal.png -------------------------------------------------------------------------------- /php/stream.php: -------------------------------------------------------------------------------- 1 | getId()); 27 | 28 | /** 29 | * Answer Proxy Request 30 | */ 31 | if( !empty( $_GET['id'] ) ){ 32 | $id = $_GET['id']; 33 | 34 | // id ok? 35 | if( is_numeric( $id ) && preg_replace('/[^0-9]/','', $id ) === $id ){ 36 | if( !isset($_GET['eid']) && !isset( $_GET['track'] ) ){ // station 37 | // get url 38 | $station = $data->getById( $id ); 39 | $url = !empty($station) ? $station['url'] : ''; 40 | } 41 | else if(isset( $_GET['eid'] ) && is_numeric( $_GET['eid'] ) && preg_replace('/[^0-9]/','', $_GET['eid'] ) === $_GET['eid'] ){ // podcast episode 42 | $ed = PodcastLoader::getEpisodeData( $id, $_GET['eid'], $data ); 43 | $url = !empty($ed) ? $ed['episode']['url'] : ''; 44 | } 45 | else if(is_numeric( $_GET['track'] ) && preg_replace('/[^0-9]/','', $_GET['track'] ) === $_GET['track'] ){ 46 | $track = $_GET['track']; 47 | 48 | $urls = PodcastLoader::getMusicById( $id, $data ); 49 | $url = empty( $urls[$track] ) ? '' : $urls[$track]; 50 | } 51 | else{ 52 | $url = ''; 53 | } 54 | // station known? 55 | if( !empty($url) && filter_var( $url, FILTER_VALIDATE_URL) !== false ){ 56 | $url = filter_var($url, FILTER_SANITIZE_URL); //clean url 57 | 58 | // the proxy does not support redirects!, so do them before 59 | $url = Helper::getFinalUrl($url); 60 | 61 | if(!DOCKER_MODE){ // use a PHP based proxy 62 | SimpleProxy::open( $url ); 63 | die(); 64 | } 65 | 66 | // get hostname and url parts before and after 67 | $matches = array(); 68 | $matchok = preg_match( '/^(https?:\/\/)([^\/]+\.?[a-zA-Z]+)((?::[0-9]+)?(?:\/.*)?)$/', $url, $matches ); // get host 69 | $host = $matches[2]; 70 | 71 | // host ok? 72 | if( $matchok === 1 ){ 73 | // check ip address 74 | $ip = gethostbyname( $host ); 75 | // create url using ip 76 | $url = $matches[1] . $ip . $matches[3]; 77 | // allow only external ips 78 | if( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false ){ 79 | //let nginx do the rest 80 | header("X-Accel-Redirect: /proxy/". $host ."/?" . $url ); 81 | die(); 82 | } 83 | } 84 | } 85 | else{ 86 | Output::sendAnswer('Invalid ID'); 87 | } 88 | } 89 | } 90 | Output::sendAnswer('No ID given!'); 91 | die(); //will never be reached 92 | ?> -------------------------------------------------------------------------------- /screenshots/Readme.md: -------------------------------------------------------------------------------- 1 | ## Screenshots of the Web Interface (GUI) of Radio-API 2 | 3 | The GUI is used to manage radio stations and podcasts. 4 | It also provides a preview of the items shown in the radio's display. 5 | 6 | ### Login 7 | > `https(s)://radio.example.com/gui/` 8 | 9 | ![Login](./login.png) 10 | 11 | ### Edit Podcasts or Radio Stations 12 | > `https(s)://radio.example.com/gui/` 13 | 14 | ![Edit Radio Stations](./edit-radio.png) 15 | 16 | ![Edit Podcasts](./edit-podcast.png) 17 | 18 | ### Preview 19 | > `https(s)://radio.example.com/gui/viewer.php` (or the bottom of the edit podcasts or radio stations page) 20 | 21 | ![Preview 1](./preview-1.png) 22 | 23 | ![Preview 2](./preview-2.png) -------------------------------------------------------------------------------- /screenshots/edit-podcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KIMB-technologies/Radio-API/449acbd4147e3762a7a7d28a6cb2ecd7eb80529e/screenshots/edit-podcast.png -------------------------------------------------------------------------------- /screenshots/edit-radio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KIMB-technologies/Radio-API/449acbd4147e3762a7a7d28a6cb2ecd7eb80529e/screenshots/edit-radio.png -------------------------------------------------------------------------------- /screenshots/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KIMB-technologies/Radio-API/449acbd4147e3762a7a7d28a6cb2ecd7eb80529e/screenshots/login.png -------------------------------------------------------------------------------- /screenshots/preview-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KIMB-technologies/Radio-API/449acbd4147e3762a7a7d28a6cb2ecd7eb80529e/screenshots/preview-1.png -------------------------------------------------------------------------------- /screenshots/preview-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KIMB-technologies/Radio-API/449acbd4147e3762a7a7d28a6cb2ecd7eb80529e/screenshots/preview-2.png -------------------------------------------------------------------------------- /utils/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM kimbtechnologies/php_nginx:latest 2 | 3 | # php redis support 4 | RUN apk add --update --no-cache $PHPIZE_DEPS \ 5 | && pecl install redis \ 6 | && docker-php-ext-enable redis 7 | 8 | # SVG -> PNG convert 9 | RUN apk add --update --no-cache rsvg-convert 10 | 11 | # copy php files, nginx conf and startup scripts 12 | COPY --chown=www-data:www-data ./php/ /php-code/ 13 | COPY ./utils/nginx.conf /etc/nginx/more-server-conf.conf 14 | COPY ./utils/startup.php /utils/startup-before.sh ./utils/cron.php / 15 | 16 | # backup default data dir 17 | RUN mkdir /data-dir-default/ \ 18 | && cp -r /php-code/data/* /data-dir-default \ 19 | && mkdir /media-dir-default/ \ 20 | && cp -r /php-code/media/* /media-dir-default 21 | 22 | ENV DOCKER_MODE=true -------------------------------------------------------------------------------- /utils/backup-restore.php: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/php 2 | $d ){ 72 | echo " " . $id . PHP_EOL; 73 | foreach( $d as $r ){ 74 | echo " " . $r . PHP_EOL; 75 | } 76 | } 77 | 78 | echo "Import RadioBrowser: " . PHP_EOL; 79 | foreach(RadioBrowser::loadFromDisk($dataDir) as $id => $d ){ 80 | echo " " . $id . " has " . count($d) . " last stations." .PHP_EOL; 81 | } 82 | } 83 | 84 | echo PHP_EOL . "Bye!" . PHP_EOL; 85 | ?> -------------------------------------------------------------------------------- /utils/cron.php: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/php 2 | -------------------------------------------------------------------------------- /utils/getr.php: -------------------------------------------------------------------------------- 1 | pconnect('redis'); 16 | 17 | $all = array(); 18 | $iterator = NULL; 19 | do { 20 | $keys = $redis->scan($iterator); 21 | if ($keys !== FALSE) { 22 | $all = array_merge( $all, $keys); 23 | } 24 | } while ($iterator > 0); 25 | 26 | echo '=================================' . PHP_EOL; 27 | echo 'Key' . "\t\t\t\t : " . 'Value' . PHP_EOL; 28 | echo '---------------------------------' . PHP_EOL; 29 | foreach( $all as $key ){ 30 | if( $redis->type($key) !== Redis::REDIS_HASH ){ 31 | $val = $redis->get($key); 32 | } 33 | else { 34 | $val = print_r( $redis->hGetAll($key), true); 35 | } 36 | echo $key . "\t\t\t\t : " . substr( $val, 0, 1024 ) . PHP_EOL; 37 | } 38 | echo '=================================' . PHP_EOL . PHP_EOL; 39 | ?> -------------------------------------------------------------------------------- /utils/nginx.conf: -------------------------------------------------------------------------------- 1 | error_page 404 /gui/index.php?err=404; 2 | error_page 403 /gui/index.php?err=403; 3 | 4 | location / { 5 | try_files $uri $uri/ @nofile; 6 | } 7 | 8 | location ~ ^/(data|classes){ 9 | deny all; 10 | return 403; 11 | } 12 | 13 | # internal proxy to support ssl streams 14 | location ~* "^/proxy/([^/]*)/?.*$" { 15 | internal; 16 | 17 | proxy_pass $args; 18 | proxy_set_header Host $1; 19 | proxy_buffering off; 20 | proxy_connect_timeout 5s; 21 | proxy_redirect https://$1/ /stream.php?url=https://$1/; 22 | proxy_redirect http://$1/ /stream.php?url=http://$1/; 23 | } 24 | 25 | location @nofile { 26 | rewrite ^(.*)$ /index.php?uri=$1 last; 27 | } -------------------------------------------------------------------------------- /utils/router.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utils/startup-before.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # file to setup docker container and update data to newer version 3 | 4 | # migrations (currently none) 5 | 6 | # create default data dir, if does not exist 7 | if [ ! -f /php-code/data/radios_1.json ]; then 8 | mv /data-dir-default/* /php-code/data/ 9 | fi; 10 | 11 | # create default media dir, if does not exist 12 | if [ ! -f /php-code/media/default.png ]; then 13 | mv /media-dir-default/* /php-code/media/ 14 | fi; 15 | 16 | # init redis with env vars 17 | php /startup.php 18 | 19 | # file permissions 20 | chown -R www-data:www-data /php-code/data/ 21 | chown -R www-data:www-data /php-code/media/ 22 | -------------------------------------------------------------------------------- /utils/startup.php: -------------------------------------------------------------------------------- 1 | removeGroup(); 30 | 31 | /** 32 | * load un/read episodes into Redis Cache 33 | */ 34 | echo "Load (Un-)Read: " . PHP_EOL; 35 | foreach(UnRead::loadFromDisk() as $id => $d ){ 36 | echo "\t" . $id . PHP_EOL; 37 | foreach( $d as $r ){ 38 | echo "\t\t" . $r . PHP_EOL; 39 | } 40 | } 41 | 42 | /** 43 | * load last stations into Redis Cache 44 | */ 45 | echo "Load RadioBrowser: " . PHP_EOL; 46 | foreach(RadioBrowser::loadFromDisk() as $id => $d ){ 47 | echo "\t" . $id . " has " . count($d) . " last stations." .PHP_EOL; 48 | } 49 | ?> 50 | --------------------------------------------------------------------------------