├── .dockerignore ├── .github └── workflows │ └── docker.yml ├── .gitignore ├── .prettierrc.json ├── Dockerfile ├── Dockerfile.test ├── LICENSE ├── Procfile ├── README.md ├── Sonos.wsdl ├── api.php ├── app.json ├── composer.json ├── composer.lock ├── css ├── bootstrap.css └── site.css ├── debug.php ├── docker-compose.test.yml ├── fpm_custom.conf ├── images ├── customsd.png ├── overcast-service.png ├── player.png ├── playlist.png └── sonos-ip.png ├── index.php ├── overcast.php ├── pmap.xml ├── setup.php ├── smapi.php ├── sonos.php ├── strings.xml └── test.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | .git/ 3 | .gitignore 4 | Dockerfile 5 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | env: 10 | IMAGE_NAME: overcast-sonos 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v1 18 | 19 | - name: Run tests 20 | run: | 21 | docker-compose --file docker-compose.test.yml build 22 | docker-compose --file docker-compose.test.yml run sut 23 | 24 | push: 25 | needs: test 26 | 27 | runs-on: ubuntu-latest 28 | if: github.event_name == 'push' 29 | 30 | steps: 31 | - uses: actions/checkout@v1 32 | 33 | - name: Build image 34 | run: docker build . --file Dockerfile --tag image 35 | 36 | - name: Log into registry 37 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u ${{ github.actor }} --password-stdin 38 | 39 | - name: Push image 40 | run: | 41 | IMAGE_ID=docker.pkg.github.com/${{ github.repository }}/$IMAGE_NAME 42 | 43 | # Strip git ref prefix from version 44 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 45 | 46 | # Use Docker `latest` tag convention 47 | [ "$VERSION" == "master" ] && VERSION=latest 48 | 49 | echo IMAGE_ID=$IMAGE_ID 50 | echo VERSION=$VERSION 51 | 52 | docker tag image $IMAGE_ID:$VERSION 53 | docker push $IMAGE_ID:$VERSION 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7.2-apache 2 | 3 | RUN apt-get update && apt-get install -y libxml2-dev \ 4 | && docker-php-ext-install soap 5 | 6 | RUN apt-get update && apt-get install -y libmemcached-dev zlib1g-dev \ 7 | && pecl install memcached-3.0.4 \ 8 | && docker-php-ext-enable memcached 9 | 10 | RUN echo "date.timezone = UTC" > /usr/local/etc/php/conf.d/timezone.ini 11 | 12 | COPY . /var/www/html/ 13 | 14 | EXPOSE 80 15 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM php:7.2-cli 2 | 3 | ADD . . 4 | CMD ["bash", "test.sh"] 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Joshua Peek 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: vendor/bin/heroku-php-apache2 -F fpm_custom.conf 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unofficial Overcast + Sonos integration 2 | 3 | Listen to your Overcast podcasts on Sonos. 4 | 5 | Just want to try it out? [Follow these setup instructions to register the Overcast service on your Sonos system](#setup). 6 | 7 | 8 | 9 | 10 | ## Setup 11 | 12 | ### Find the IP address of your Sonos. 13 | 14 | From the iOS Sonos App, go to **Settings** > **About My Sonos System**. 15 | 16 | 17 | 18 | [Check out this Sonos support article if you're on another platform.](https://sonos.custhelp.com/app/answers/detail/a_id/2626/) 19 | 20 | ### Register the custom service 21 | 22 | Navigate to `http://$IP:1400/customsd`. For example, if your Sonos IP address was `10.0.1.10`, you'd navigate to `http://10.0.1.10:1400/customsd` in your web browser. 23 | 24 | Fill out the custom service registration form with the following values. 25 | 26 | 27 | 28 | For the purpose of copy and paste: 29 | 30 | - http://overcast-sonos.herokuapp.com/smapi.php 31 | - http://overcast-sonos.herokuapp.com/strings.xml 32 | - http://overcast-sonos.herokuapp.com/pmap.xml 33 | 34 | ### Add Music Service 35 | 36 | From the iOS Sonos App, go to **Add Music Services** and find **Overcast** in the list. Log in with your [Overcast.fm](https://overcast.fm/login) email address and password. 37 | 38 | ## Questions and Answers 39 | 40 | #### Is this an official Overcast service? 41 | 42 | No. 43 | 44 | #### Do I have to run this thing on my computer? 45 | 46 | Unlike [AirSonos](http://airsonos.stephenwan.net/), you don't need to run any additional programs on your computer or network. A public instance of the service is already set up on Heroku. 47 | 48 | #### Why can't I see my playlists? 49 | 50 | This service is limited to the same functionality as the [overcast.fm website](https://overcast.fm/), which only supports the "All Active Episodes" playlist. 51 | 52 | #### What does "registering a custom service" do? 53 | 54 | [Sonos allows a unreviewed custom service to be registered for testing and development purposes](http://musicpartners.sonos.com/docs?q=node/134). It's similar to "sideloading" an iOS app via Xcode. Typically, all services are reviewed and registered with Sonos. 55 | 56 | Keep in mind that only one custom service can be registered per network. Because of this limitation, you can't use this Overcast service and another like the [NPR One service](https://michaeldick.me/sonos-nprone/) at the same time. 57 | 58 | #### Why isn't there a nice Overcast logo in the Sonos app? 59 | 60 | Only officially registered services can [set a custom service logo](http://musicpartners.sonos.com/node/377). 61 | 62 | #### Why PHP? 63 | 64 | I thought it would be fun and maybe [@marcoarment](https://github.com/marcoarment) would take the source and make it official. 65 | 66 | #### Can I run my own service? 67 | 68 | Yeah! If you want to make modifications or want to control your own instance for privacy reasons, you can simply use this "Deploy to Heroku" button to quickly set up your own app in minutes. 69 | 70 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/josh/overcast-sonos) 71 | -------------------------------------------------------------------------------- /Sonos.wsdl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 23 | 24 | 25 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | Extensibility point to add new context header information. 166 | Please refer to Sonos documentation for details 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | It is a token that is specific to a device to allow that device to stream. 203 | If a deviceSessionToken was returned in a previous call, this is provided here. There may be 204 | some tracks that do not include one in the mediaUriResponse; this will not cause Sonos to 205 | remove the previous deviceSessionToken. Sonos will send this element until a new one is returned, always. 206 | This element can be used to avoid having to re-validate the cert by encoding that information. 207 | It can provide a lower cost short cut for encoding session keys. 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 254 | 255 | 257 | 258 | 261 | 262 | 263 | 264 | 265 | 266 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 288 | 289 | 290 | 291 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 320 | 321 | 322 | 323 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 388 | 389 | 390 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 408 | 409 | 410 | 415 | 416 | 417 | 421 | 422 | 423 | 426 | 427 | 428 | 431 | 432 | 433 | 436 | 437 | 438 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 452 | 453 | 454 | 455 | 456 | 457 | 459 | 460 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 504 | 505 | 506 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | segmentMetadata is metadata that applies to a specified time range. 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | Specifies the inclusive start time of the period to which this metadata 622 | applies. 623 | 624 | 625 | 626 | 627 | 628 | Specifies the length of the period in milliseconds. 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | Specifies the inclusive start time of the period for this list. If 662 | omitted, defaults the startTime of the first temporalMediaMetadata element. 663 | 664 | 665 | 666 | 667 | 668 | Specifies the length of the period of this list in milliseconds. If 669 | omitted, defaults the duration between startTime and the last elment's end time. 670 | 671 | 672 | 673 | 674 | 675 | A chronologically ordered list of segmentMetadata elements 676 | 677 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | 685 | 686 | 687 | 688 | 690 | 691 | 692 | 693 | 694 | 695 | 696 | 697 | 698 | 699 | 700 | 701 | 702 | 703 | 704 | 705 | 706 | 707 | 708 | 709 | 710 | 711 | 712 | 713 | 714 | 715 | 716 | 717 | 718 | 719 | 720 | 721 | 722 | 723 | 724 | 725 | 726 | 727 | 728 | 729 | 730 | 731 | 732 | 733 | 734 | 735 | 736 | 737 | 738 | 739 | 740 | 741 | 742 | 743 | 744 | 745 | 746 | 747 | 748 | 749 | 750 | 751 | 752 | 753 | 754 | 755 | 756 | 757 | 758 | 759 | 760 | 761 | 762 | 763 | 764 | 765 | 766 | 767 | 768 | 769 | 770 | 771 | 772 | 773 | 774 | 775 | 776 | 777 | 778 | 779 | 780 | 781 | 782 | 783 | 784 | 785 | 786 | 787 | 788 | 789 | 790 | 791 | 792 | 793 | 794 | 795 | 796 | 797 | 798 | 799 | 800 | 801 | 802 | 803 | 804 | 805 | 806 | 807 | 808 | 809 | 810 | 811 | 812 | 813 | 814 | 815 | 816 | 817 | 818 | 819 | 820 | 821 | 822 | 823 | 824 | 825 | 826 | 827 | 828 | 829 | 830 | 831 | 832 | 833 | 834 | 835 | 836 | 837 | 838 | 839 | 840 | 841 | 842 | 843 | 844 | 845 | 846 | 847 | 848 | 849 | 850 | 851 | 852 | 853 | 854 | 855 | 856 | 857 | 858 | 859 | 860 | 861 | 862 | 863 | 864 | 865 | 866 | 867 | 868 | 869 | 870 | 871 | 872 | 873 | 874 | 875 | 876 | 877 | 878 | 879 | 880 | 881 | 882 | 883 | 884 | 885 | 886 | 887 | 888 | 889 | 890 | 891 | 892 | 893 | 894 | 895 | 896 | 897 | 898 | 899 | 900 | 901 | 902 | 903 | 904 | 905 | 906 | 907 | 908 | 909 | 910 | 911 | 912 | 913 | 914 | 915 | 916 | 917 | 918 | 919 | 920 | 921 | 922 | 923 | 924 | 925 | 926 | 927 | 928 | 929 | 930 | 931 | 932 | 933 | 934 | 935 | 936 | 937 | 938 | 939 | 940 | 941 | 942 | 943 | 944 | 945 | 946 | 947 | 948 | 949 | 950 | 951 | 952 | 953 | 954 | 956 | 957 | 958 | 959 | 960 | 961 | 962 | 963 | 964 | 965 | 966 | 967 | 968 | 969 | 970 | 971 | 972 | 973 | 974 | 975 | 976 | 977 | 978 | 979 | 980 | 981 | 982 | 983 | 984 | 985 | 986 | 987 | 988 | 989 | 990 | 991 | 992 | 993 | 994 | 995 | 996 | 997 | 998 | 1002 | 1003 | 1004 | 1005 | 1006 | 1007 | 1008 | 1009 | 1010 | 1011 | 1012 | 1013 | 1014 | 1015 | 1016 | 1017 | 1018 | 1019 | 1020 | 1021 | 1022 | 1023 | 1024 | 1025 | 1026 | 1027 | 1028 | 1029 | 1030 | 1031 | 1032 | 1033 | 1034 | 1035 | 1036 | 1037 | 1038 | 1039 | 1040 | 1041 | 1042 | 1043 | 1044 | 1045 | 1046 | 1047 | 1048 | 1049 | 1050 | 1051 | 1052 | 1053 | 1054 | 1055 | 1056 | 1057 | 1058 | 1059 | 1060 | 1061 | 1062 | 1063 | 1064 | 1065 | 1066 | 1067 | 1068 | 1069 | 1070 | 1071 | 1072 | 1073 | 1074 | 1075 | 1076 | 1077 | 1078 | 1079 | 1080 | 1081 | 1082 | 1083 | 1084 | 1085 | 1086 | 1087 | 1088 | 1089 | 1090 | 1091 | 1092 | 1093 | 1094 | 1095 | 1096 | 1097 | 1098 | 1099 | 1100 | 1101 | 1102 | 1103 | 1104 | 1105 | 1106 | 1107 | 1108 | 1109 | 1110 | 1111 | 1112 | 1113 | 1114 | 1115 | 1116 | 1117 | 1118 | 1119 | 1120 | 1121 | 1122 | 1123 | 1124 | 1125 | 1126 | 1127 | 1128 | 1129 | 1130 | 1131 | 1132 | 1133 | 1134 | 1135 | 1137 | 1138 | 1139 | 1140 | 1141 | 1142 | 1143 | 1144 | 1145 | 1146 | 1147 | 1148 | 1149 | 1150 | 1151 | 1152 | 1153 | 1154 | 1155 | 1156 | 1157 | 1158 | 1159 | 1160 | 1161 | 1162 | 1163 | 1164 | 1165 | 1166 | 1167 | 1168 | 1169 | 1170 | 1171 | 1172 | 1173 | 1174 | 1175 | 1176 | 1177 | 1178 | 1179 | 1180 | 1181 | 1182 | 1183 | 1184 | 1185 | 1186 | 1187 | 1188 | 1189 | 1190 | 1191 | 1192 | 1193 | 1194 | 1195 | 1196 | 1197 | 1198 | 1199 | 1200 | 1201 | 1202 | 1203 | 1204 | 1205 | 1206 | 1207 | 1208 | 1209 | 1210 | 1211 | 1212 | 1213 | 1214 | 1215 | 1216 | 1217 | 1218 | 1219 | 1220 | 1221 | 1222 | 1223 | 1224 | 1225 | 1226 | 1227 | 1228 | 1229 | 1230 | 1231 | 1232 | 1233 | 1234 | 1235 | 1236 | 1237 | 1238 | 1239 | 1240 | 1241 | 1242 | 1243 | 1244 | 1245 | 1246 | 1247 | 1248 | 1249 | 1250 | 1251 | 1252 | 1253 | 1254 | 1255 | 1256 | 1257 | 1258 | 1259 | 1260 | 1261 | 1262 | 1263 | 1264 | 1265 | 1266 | 1267 | 1268 | 1269 | 1270 | 1271 | 1272 | 1273 | 1274 | 1275 | 1276 | 1277 | 1278 | 1279 | 1280 | 1281 | 1282 | 1283 | 1284 | 1285 | 1286 | 1287 | 1288 | 1289 | 1290 | 1291 | 1292 | 1293 | 1294 | 1295 | 1296 | 1297 | 1298 | 1299 | 1300 | 1301 | 1302 | 1303 | 1304 | 1305 | 1306 | 1307 | 1308 | 1309 | 1310 | 1311 | 1312 | 1313 | 1314 | 1315 | 1316 | 1317 | 1318 | 1319 | 1320 | 1321 | 1322 | 1323 | 1324 | 1325 | 1326 | 1327 | 1328 | 1329 | 1330 | 1331 | 1332 | 1333 | 1334 | 1335 | 1336 | 1337 | 1338 | 1339 | 1340 | 1341 | 1342 | 1343 | 1344 | 1345 | 1346 | 1347 | 1348 | 1349 | 1350 | 1351 | 1352 | 1353 | 1354 | 1355 | 1356 | 1357 | 1358 | 1359 | 1360 | 1361 | 1362 | 1363 | 1364 | 1365 | 1366 | 1367 | 1368 | 1369 | 1370 | 1371 | 1372 | 1373 | 1374 | 1375 | 1376 | 1377 | 1378 | 1379 | 1380 | 1381 | 1382 | 1383 | 1384 | 1385 | 1386 | 1387 | 1388 | 1389 | 1390 | 1391 | 1392 | 1393 | 1394 | 1395 | 1396 | 1397 | 1398 | 1399 | 1400 | 1401 | 1402 | 1403 | 1404 | 1405 | 1406 | 1407 | 1408 | 1409 | 1410 | 1411 | 1412 | 1413 | 1414 | 1415 | 1416 | 1417 | 1418 | 1419 | 1420 | 1421 | 1422 | 1423 | 1424 | 1425 | 1426 | 1427 | 1428 | 1429 | 1430 | 1431 | 1432 | 1433 | 1434 | 1435 | 1436 | 1437 | 1438 | 1439 | 1440 | 1441 | 1442 | 1443 | 1444 | 1445 | 1446 | 1447 | 1448 | 1449 | 1450 | 1451 | 1452 | 1453 | 1454 | 1455 | 1456 | 1457 | 1458 | 1459 | 1460 | 1461 | 1462 | 1463 | 1464 | 1465 | 1466 | 1467 | 1468 | 1469 | 1470 | 1471 | 1472 | 1473 | 1474 | 1475 | 1476 | 1477 | 1478 | 1479 | 1480 | 1481 | 1482 | 1483 | 1484 | 1485 | 1486 | 1487 | 1488 | 1489 | 1490 | 1491 | 1492 | 1493 | 1494 | 1495 | 1496 | 1497 | 1498 | 1499 | 1500 | 1501 | 1502 | 1503 | 1504 | 1505 | 1506 | 1507 | 1508 | 1509 | 1510 | 1511 | 1512 | 1513 | 1514 | 1515 | 1516 | 1517 | 1518 | 1519 | 1520 | 1521 | 1522 | 1523 | 1524 | 1525 | 1526 | 1527 | 1528 | 1529 | 1530 | 1531 | 1532 | 1533 | 1534 | 1535 | 1536 | 1537 | 1538 | 1539 | 1540 | 1541 | 1542 | 1543 | 1544 | 1545 | 1546 | 1547 | 1548 | 1549 | 1550 | 1551 | 1552 | 1553 | 1554 | 1555 | 1556 | 1557 | 1558 | 1559 | 1560 | 1561 | 1562 | 1563 | 1564 | 1565 | 1566 | 1567 | 1568 | 1569 | 1570 | 1571 | 1572 | 1573 | 1574 | 1575 | 1576 | 1577 | 1578 | 1579 | 1580 | 1581 | 1582 | 1583 | 1584 | 1585 | 1586 | 1587 | 1588 | 1589 | 1590 | 1591 | 1592 | 1593 | 1594 | 1595 | 1596 | 1597 | 1598 | 1599 | 1600 | 1601 | 1602 | 1603 | 1604 | 1605 | 1606 | 1607 | 1608 | 1609 | 1610 | 1611 | 1612 | 1613 | 1614 | 1615 | 1616 | 1617 | 1618 | 1619 | 1620 | 1621 | 1622 | 1623 | 1624 | 1625 | 1626 | 1627 | 1628 | 1629 | 1630 | 1631 | 1632 | 1633 | 1634 | 1635 | 1636 | 1637 | 1638 | 1639 | 1640 | 1641 | 1642 | 1643 | 1644 | 1645 | 1646 | 1647 | 1648 | 1649 | 1650 | 1651 | 1652 | 1653 | 1654 | 1655 | 1656 | 1657 | 1658 | 1659 | 1660 | 1661 | 1662 | 1663 | 1664 | 1665 | 1666 | 1667 | 1668 | 1669 | 1670 | 1671 | 1672 | 1673 | 1674 | 1675 | 1676 | 1677 | 1678 | 1679 | 1680 | 1681 | 1682 | 1683 | 1684 | 1685 | 1686 | 1687 | 1688 | 1689 | 1690 | 1691 | 1692 | 1693 | 1694 | 1695 | 1696 | 1697 | 1698 | 1699 | 1700 | 1701 | 1702 | 1703 | 1704 | 1705 | 1706 | 1707 | 1708 | 1709 | 1710 | 1711 | 1712 | 1713 | 1714 | 1715 | 1716 | 1717 | 1718 | 1719 | 1720 | 1721 | 1722 | 1723 | 1724 | 1725 | 1726 | 1727 | 1728 | 1729 | 1730 | 1731 | 1732 | 1733 | 1734 | 1735 | 1736 | 1737 | 1738 | 1739 | 1740 | 1741 | 1742 | 1743 | 1744 | 1745 | 1746 | 1747 | 1748 | 1749 | 1750 | 1751 | 1752 | 1753 | 1754 | 1755 | 1756 | 1757 | 1758 | 1759 | 1760 | 1761 | 1762 | 1763 | 1764 | 1765 | 1766 | 1767 | 1768 | 1769 | 1770 | 1771 | 1772 | 1773 | 1774 | 1775 | 1776 | 1777 | 1778 | 1779 | 1780 | 1781 | 1782 | 1783 | 1784 | 1785 | 1786 | 1787 | 1788 | 1789 | 1790 | 1791 | 1792 | 1793 | 1794 | 1795 | 1796 | 1797 | 1798 | 1799 | 1800 | 1801 | 1802 | 1803 | 1804 | 1805 | 1806 | 1807 | 1808 | 1809 | 1810 | 1811 | 1812 | 1813 | 1814 | 1815 | 1816 | 1817 | 1818 | 1819 | 1820 | 1821 | 1822 | 1823 | 1824 | 1825 | 1826 | 1827 | 1828 | 1829 | 1830 | 1831 | 1832 | 1833 | 1834 | 1835 | 1836 | 1837 | 1838 | 1839 | 1840 | 1841 | 1842 | 1843 | 1844 | 1845 | 1846 | 1847 | 1848 | 1849 | 1850 | 1851 | 1852 | 1853 | 1854 | 1855 | 1856 | 1857 | 1858 | 1859 | 1860 | 1861 | 1862 | 1863 | 1864 | 1865 | 1866 | 1867 | 1868 | 1869 | 1870 | 1871 | 1872 | 1873 | 1874 | 1875 | 1876 | 1877 | 1878 | 1879 | 1880 | 1881 | 1882 | 1883 | 1884 | 1885 | 1886 | 1887 | 1888 | 1889 | 1890 | 1891 | 1892 | 1893 | 1894 | 1895 | 1896 | 1897 | 1898 | 1899 | 1900 | 1901 | 1902 | 1903 | 1904 | 1905 | 1906 | 1907 | 1908 | 1909 | 1910 | 1911 | 1912 | 1913 | 1914 | 1915 | 1916 | 1917 | 1918 | 1919 | 1920 | 1921 | 1922 | 1923 | 1924 | 1925 | 1926 | 1927 | 1928 | 1929 | 1930 | 1931 | 1932 | 1933 | 1934 | 1935 | 1936 | 1937 | 1938 | 1939 | 1940 | 1941 | 1942 | 1943 | 1944 | 1945 | 1946 | 1947 | 1948 | 1949 | 1950 | 1951 | 1952 | 1953 | 1954 | 1955 | 1956 | 1957 | 1958 | 1959 | 1960 | 1961 | 1962 | 1963 | 1964 | 1965 | 1966 | 1967 | 1968 | 1969 | 1970 | 1971 | 1972 | 1973 | 1974 | 1975 | 1976 | 1977 | 1978 | 1979 | 1980 | 1981 | 1982 | 1983 | 1984 | 1985 | 1986 | 1987 | 1988 | 1989 | 1990 | 1991 | 1992 | 1993 | 1994 | 1995 | 1996 | 1997 | 1998 | 1999 | 2000 | 2001 | 2002 | 2003 | 2004 | 2005 | 2006 | 2007 | 2008 | 2009 | 2010 | 2011 | 2012 | 2013 | 2014 | 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025 | 2026 | 2027 | 2028 | 2029 | 2030 | 2031 | 2032 | 2033 | 2034 | 2035 | 2036 | 2037 | 2038 | 2039 | 2040 | 2041 | 2042 | 2043 | 2044 | 2045 | 2046 | 2047 | 2048 | 2049 | 2050 | 2051 | 2052 | 2053 | 2054 | 2055 | 2056 | 2057 | 2058 | 2059 | 2060 | 2061 | 2062 | 2063 | 2064 | 2065 | 2066 | 2067 | 2068 | 2069 | 2070 | 2071 | 2072 | 2073 | 2074 | 2075 | 2076 | 2077 | 2078 | 2079 | 2080 | 2081 | 2082 | 2083 | 2084 | 2085 | 2086 | 2087 | 2088 | 2089 | 2090 | 2091 | 2092 | 2093 | 2094 | 2095 | 2096 | 2097 | 2098 | 2099 | 2100 | 2101 | 2102 | 2103 | 2104 | 2105 | 2106 | 2107 | 2108 | 2109 | 2110 | 2111 | 2112 | 2113 | 2114 | 2115 | 2116 | 2117 | 2118 | 2119 | 2120 | 2121 | 2122 | 2123 | 2124 | 2125 | 2126 | 2127 | 2128 | 2129 | 2130 | 2131 | 2132 | 2133 | 2134 | 2135 | 2136 | 2137 | 2138 | 2139 | 2140 | 2141 | 2142 | 2143 | 2144 | 2145 | 2146 | 2147 | 2148 | 2149 | 2150 | 2151 | 2152 | 2153 | 2154 | 2155 | 2156 | 2157 | 2158 | 2159 | 2160 | 2161 | 2162 | 2163 | 2164 | 2165 | 2166 | 2167 | 2168 | 2169 | 2170 | 2171 | 2172 | -------------------------------------------------------------------------------- /api.php: -------------------------------------------------------------------------------- 1 | sessionId = $_GET['sessionId']; 23 | $sonos = new Sonos($params); 24 | $sonos->credentials($params); 25 | echo json_encode($sonos->getLastUpdate($params)); 26 | break; 27 | case 'getMetadata': 28 | $params = new StdClass(); 29 | $params->sessionId = $_GET['sessionId']; 30 | $params->count = intval($_GET['count']); 31 | $params->id = $_GET['id']; 32 | $params->index = intval($_GET['index']); 33 | $sonos = new Sonos($params); 34 | $sonos->credentials($params); 35 | echo json_encode($sonos->getMetadata($params)); 36 | break; 37 | default: 38 | http_response_code(400); 39 | } 40 | ?> 41 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Overcast + Sonos", 3 | "description": "Listen to your Overcast podcasts on Sonos.", 4 | "repository": "https://github.com/josh/overcast-sonos", 5 | "keywords": ["overcast", "sonos"], 6 | "success_url": "/setup.php", 7 | "addons": ["memcachier:dev"] 8 | } 9 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "php": "^7.2.0", 4 | "ext-memcached": "*", 5 | "ext-soap": "*" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "ea62f4cfe8dfb61fb8c4857c6cbe8262", 8 | "packages": [], 9 | "packages-dev": [], 10 | "aliases": [], 11 | "minimum-stability": "stable", 12 | "stability-flags": [], 13 | "prefer-stable": false, 14 | "prefer-lowest": false, 15 | "platform": { 16 | "php": "^7.2.0", 17 | "ext-memcached": "*", 18 | "ext-soap": "*" 19 | }, 20 | "platform-dev": [] 21 | } 22 | -------------------------------------------------------------------------------- /css/site.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 50px; 3 | } 4 | 5 | .header { 6 | text-align: center; 7 | } 8 | 9 | footer { 10 | padding: 100px 0 15px; 11 | text-align: center; 12 | } 13 | 14 | .featurette-divider { 15 | margin: 80px 0; 16 | } 17 | 18 | .featurette-heading { 19 | line-height: 1; 20 | letter-spacing: -1px; 21 | } 22 | 23 | .shell-example { 24 | width: 500px; 25 | } 26 | 27 | .github-corner:hover .octo-arm { 28 | animation: octocat-wave 560ms ease-in-out; 29 | } 30 | 31 | @keyframes octocat-wave { 32 | 0%, 33 | 100% { 34 | transform: rotate(0); 35 | } 36 | 20%, 37 | 60% { 38 | transform: rotate(-25deg); 39 | } 40 | 40%, 41 | 80% { 42 | transform: rotate(10deg); 43 | } 44 | } 45 | 46 | @media (max-width: 500px) { 47 | .github-corner:hover .octo-arm { 48 | animation: none; 49 | } 50 | .github-corner .octo-arm { 51 | animation: octocat-wave 560ms ease-in-out; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /debug.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 |
30 | 31 |
32 | 33 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | sut: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile.test 7 | depends_on: 8 | - "web" 9 | web: 10 | build: 11 | context: . 12 | dockerfile: Dockerfile 13 | -------------------------------------------------------------------------------- /fpm_custom.conf: -------------------------------------------------------------------------------- 1 | php_value[always_populate_raw_post_data]=-1 2 | -------------------------------------------------------------------------------- /images/customsd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/overcast-sonos/6d828de0a3a7364fa444532f31d233f54bf964fd/images/customsd.png -------------------------------------------------------------------------------- /images/overcast-service.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/overcast-sonos/6d828de0a3a7364fa444532f31d233f54bf964fd/images/overcast-service.png -------------------------------------------------------------------------------- /images/player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/overcast-sonos/6d828de0a3a7364fa444532f31d233f54bf964fd/images/player.png -------------------------------------------------------------------------------- /images/playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/overcast-sonos/6d828de0a3a7364fa444532f31d233f54bf964fd/images/playlist.png -------------------------------------------------------------------------------- /images/sonos-ip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh/overcast-sonos/6d828de0a3a7364fa444532f31d233f54bf964fd/images/sonos-ip.png -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /overcast.php: -------------------------------------------------------------------------------- 1 | setOption(Memcached::OPT_BINARY_PROTOCOL, true); 4 | 5 | if (getenv('MEMCACHIER_USERNAME') && getenv('MEMCACHIER_PASSWORD')) { 6 | $memcache->setSaslAuthData( 7 | getenv('MEMCACHIER_USERNAME'), 8 | getenv('MEMCACHIER_PASSWORD') 9 | ); 10 | } 11 | 12 | if (!$memcache->getServerList()) { 13 | if (getenv('MEMCACHIER_SERVERS')) { 14 | $servers = explode(',', getenv('MEMCACHIER_SERVERS')); 15 | for ($i = 0; $i < count($servers); $i++) { 16 | $servers[$i] = explode(':', $servers[$i]); 17 | } 18 | $memcache->addServers($servers); 19 | } else { 20 | $memcache->addServer('127.0.0.1', 11211); 21 | } 22 | } 23 | 24 | function encrypt($data, $key) 25 | { 26 | $iv = openssl_random_pseudo_bytes(16); 27 | return $iv . ":" . openssl_encrypt($data, 'AES-256-CBC', $key, true, $iv); 28 | } 29 | 30 | function decrypt($data, $key) 31 | { 32 | $iv = substr($data, 0, 16); 33 | return openssl_decrypt(substr($data, 17), 'AES-256-CBC', $key, true, $iv); 34 | } 35 | 36 | class Podcast 37 | { 38 | public $id; 39 | public $title; 40 | public $imageURL; 41 | public $episodeIDs; 42 | public $episodeDurations; 43 | } 44 | 45 | class Episode 46 | { 47 | public $id; 48 | public $number; 49 | public $podcastId; 50 | public $podcastTitle; 51 | public $title; 52 | public $description; 53 | public $date; 54 | public $imageURL; 55 | public $duration; 56 | public $mimeType; 57 | public $url; 58 | public $itemId; 59 | public $speedId; 60 | } 61 | 62 | function fetch($url, $token = null) 63 | { 64 | $ch = curl_init(); 65 | 66 | curl_setopt($ch, CURLOPT_URL, $url); 67 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 68 | curl_setopt($ch, CURLOPT_HEADER, 0); 69 | 70 | if ($token) { 71 | curl_setopt($ch, CURLOPT_HTTPHEADER, ["Cookie: o=$token"]); 72 | } 73 | 74 | $body = curl_exec($ch); 75 | 76 | curl_close($ch); 77 | 78 | return $body; 79 | } 80 | 81 | function getAccountLastUpdate($token) 82 | { 83 | return sha1(serialize(fetchAccount($token))); 84 | } 85 | 86 | function fetchAccount($token) 87 | { 88 | global $memcache; 89 | 90 | $key = "overcast:fetchAccount:v3:" . sha1($token); 91 | $data = $memcache->get($key); 92 | if ($data) { 93 | return unserialize(decrypt($data, $token)); 94 | } 95 | 96 | $body = fetch("https://overcast.fm/podcasts", $token); 97 | 98 | libxml_use_internal_errors(true); 99 | 100 | $dom = new DOMDocument(); 101 | $dom->loadHTML($body); 102 | 103 | $xpath = new DOMXpath($dom); 104 | 105 | $result = new StdClass(); 106 | $result->episodeIDs = array(); 107 | $result->podcastIDs = array(); 108 | 109 | foreach ($xpath->query('//a[@class="feedcell"]') as $cell) { 110 | $id = substr($cell->getAttribute('href'), 1); 111 | if ($id != 'uploads') { 112 | $result->podcastIDs[] = $id; 113 | } 114 | } 115 | 116 | foreach ($xpath->query('//a[@class="episodecell"]') as $cell) { 117 | $id = substr($cell->getAttribute('href'), 1); 118 | $result->episodeIDs[] = $id; 119 | } 120 | 121 | $memcache->set($key, encrypt(serialize($result), $token), time() + 150); 122 | 123 | return $result; 124 | } 125 | 126 | function invalidateAccountCache($token) 127 | { 128 | global $memcache; 129 | 130 | $key = "overcast:fetchAccount:v3:" . sha1($token); 131 | $memcache->delete($key); 132 | } 133 | 134 | function fetchPodcast($id) 135 | { 136 | global $memcache; 137 | 138 | if (substr($id, 0, 1) == '+') { 139 | throw new Exception("invalid podcast id"); 140 | } 141 | 142 | $key = "overcast:fetchPodcast:v4:$id"; 143 | $data = $memcache->get($key); 144 | if ($data) { 145 | return unserialize($data); 146 | } 147 | 148 | $body = fetch("https://overcast.fm/" . $id); 149 | 150 | preg_match('/extendedepisodecell/', $body, $matches); 151 | if (!isset($matches[0])) { 152 | $memcache->set($key, serialize(null), time() + 86400); 153 | return null; 154 | } 155 | 156 | libxml_use_internal_errors(true); 157 | 158 | $dom = new DOMDocument(); 159 | $dom->loadHTML($body); 160 | 161 | $xpath = new DOMXpath($dom); 162 | 163 | $podcast = new Podcast(); 164 | $podcast->id = $id; 165 | 166 | $podcast->title = $xpath->query('//h2[@class="centertext"]')[0]->textContent; 167 | 168 | $url = $xpath->query('//img[@class="art fullart"]')[0]->getAttribute('src'); 169 | $params = array(); 170 | parse_str(parse_url($url, PHP_URL_QUERY), $params); 171 | $podcast->imageURL = $params['u']; 172 | 173 | $podcast->episodeIDs = []; 174 | foreach ( 175 | $xpath->query('//a[contains(@class, "extendedepisodecell")]') 176 | as $a 177 | ) { 178 | $id = substr($a->getAttribute('href'), 1); 179 | $podcast->episodeIDs[] = $id; 180 | 181 | $caption = $xpath->query('.//div[@class="caption2 singleline"]', $a)[0] 182 | ->textContent; 183 | preg_match('/(\d+) min/', $caption, $matches); 184 | if (isset($matches[0])) { 185 | $podcast->episodeDurations[$id] = ((int) $matches[1] * 60); 186 | } 187 | } 188 | 189 | $memcache->set($key, serialize($podcast), time() + 86400); 190 | 191 | return $podcast; 192 | } 193 | 194 | function fetchEpisode($id) 195 | { 196 | global $memcache; 197 | 198 | $key = "overcast:fetchEpisode:v7:$id"; 199 | $data = $memcache->get($key); 200 | if ($data) { 201 | return unserialize($data); 202 | } 203 | 204 | $body = fetch("https://overcast.fm/" . $id); 205 | 206 | preg_match('/audioplayer/', $body, $matches); 207 | if (!isset($matches[0])) { 208 | $memcache->set($key, serialize(null)); 209 | return null; 210 | } 211 | 212 | libxml_use_internal_errors(true); 213 | 214 | $dom = new DOMDocument(); 215 | $dom->loadHTML($body); 216 | 217 | $xpath = new DOMXpath($dom); 218 | 219 | $episode = new Episode(); 220 | $episode->id = $id; 221 | 222 | $episode->podcastId = substr( 223 | $xpath->query('//div[@class="centertext"]/h3/a')[0]->getAttribute('href'), 224 | 1 225 | ); 226 | 227 | $podcast = fetchPodcast($episode->podcastId); 228 | 229 | if (empty($podcast->episodeDurations[$id])) { 230 | $memcache->delete("overcast:fetchPodcast:v4:" . $episode->podcastId); 231 | $podcast = fetchPodcast($episode->podcastId); 232 | } 233 | 234 | if (isset($podcast->episodeDurations[$id])) { 235 | $episode->duration = $podcast->episodeDurations[$id]; 236 | } 237 | $episode->podcastTitle = $podcast->title; 238 | 239 | $episode->title = $xpath->query( 240 | '//div[@class="centertext"]/h2' 241 | )[0]->textContent; 242 | $episode->description = $xpath 243 | ->query('//meta[@name="og:description"]')[0] 244 | ->getAttribute('content'); 245 | 246 | $dateEl = $xpath->query('//div[@class="centertext"]/div'); 247 | if (isset($dateEl[0])) { 248 | $episode->date = strftime('%Y-%m-%d', strtotime($dateEl[0]->textContent)); 249 | } 250 | 251 | preg_match('/^#?(\d+)\s*(:|-|–|—)?\s+/', $episode->title, $matches); 252 | if (isset($matches[0])) { 253 | $episode->title = substr($episode->title, strlen($matches[0])); 254 | $episode->number = (int) $matches[1]; 255 | } 256 | 257 | $url = $xpath->query('//meta[@name="og:image"]')[0]->getAttribute('content'); 258 | $params = array(); 259 | parse_str(parse_url($url, PHP_URL_QUERY), $params); 260 | $episode->imageURL = $params['u']; 261 | 262 | $audio = $xpath->query('//audio')[0]; 263 | $episode->itemId = $audio->getAttribute('data-item-id'); 264 | $episode->speedId = $audio->getAttribute('data-speed-id'); 265 | 266 | $source = $xpath->query('//audio/source')[0]; 267 | $episode->mimeType = $source->getAttribute('type'); 268 | $episode->url = $source->getAttribute('src'); 269 | 270 | $memcache->set($key, serialize($episode)); 271 | 272 | return $episode; 273 | } 274 | 275 | function addEpisode($token, $id) 276 | { 277 | updateEpisodeProgress($token, $id, 0); 278 | invalidateAccountCache($token); 279 | } 280 | 281 | function deleteEpisode($token, $id) 282 | { 283 | $episode = fetchEpisode($id); 284 | fetch("https://overcast.fm/podcasts/delete_item/" . $episode->itemId, $token); 285 | invalidateAccountCache($token); 286 | } 287 | 288 | function fetchEpisodeProgress($token, $id) 289 | { 290 | global $memcache; 291 | 292 | $body = fetch("https://overcast.fm/" . $id, $token); 293 | 294 | libxml_use_internal_errors(true); 295 | 296 | $dom = new DOMDocument(); 297 | $dom->loadHTML($body); 298 | 299 | $xpath = new DOMXpath($dom); 300 | 301 | $audio = $xpath->query('//audio')[0]; 302 | 303 | $itemId = $audio->getAttribute('data-item-id'); 304 | $speedId = (int) $audio->getAttribute('data-speed-id'); 305 | $version = (int) $audio->getAttribute('data-sync-version'); 306 | $position = (int) $audio->getAttribute('data-start-time'); 307 | 308 | $progress = new StdClass(); 309 | $progress->itemId = $itemId; 310 | $progress->speedId = $speedId; 311 | $progress->version = $version; 312 | $progress->position = $position; 313 | 314 | $key = "overcast:fetchEpisodeProgress:" . sha1("$token:$id"); 315 | $memcache->set($key, encrypt(serialize($progress), $token), time() + 3600); 316 | 317 | return $progress; 318 | } 319 | 320 | function updateEpisodeProgress($token, $id, $position) 321 | { 322 | global $memcache; 323 | 324 | $episode = fetchEpisode($id); 325 | 326 | if (isset($episode->duration) && $position >= $episode->duration) { 327 | $position = 2147483647; 328 | invalidateAccountCache($token); 329 | } 330 | 331 | $key = "overcast:fetchEpisodeProgress:" . sha1("$token:$id"); 332 | $rawProgress = $memcache->get($key); 333 | $progress = $rawProgress 334 | ? unserialize(decrypt($rawProgress, $token)) 335 | : fetchEpisodeProgress($token, $id); 336 | 337 | $ch = curl_init(); 338 | 339 | curl_setopt( 340 | $ch, 341 | CURLOPT_URL, 342 | "https://overcast.fm/podcasts/set_progress/" . $episode->itemId 343 | ); 344 | curl_setopt($ch, CURLOPT_HTTPHEADER, ["Cookie: o=$token"]); 345 | curl_setopt($ch, CURLOPT_POST, 2); 346 | curl_setopt( 347 | $ch, 348 | CURLOPT_POSTFIELDS, 349 | http_build_query(array( 350 | 'speed' => '' . $progress->speedId, 351 | 'v' => '' . $progress->version, 352 | 'p' => '' . $position 353 | )) 354 | ); 355 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 356 | curl_setopt($ch, CURLOPT_HEADER, 0); 357 | 358 | $version = curl_exec($ch); 359 | 360 | curl_close($ch); 361 | 362 | $progress->version = (int) $version; 363 | $progress->position = (int) $position; 364 | 365 | $key = "overcast:fetchEpisodeProgress:" . sha1("$token:$id"); 366 | $memcache->set($key, encrypt(serialize($progress), $token), time() + 3600); 367 | } 368 | 369 | function login($email, $password) 370 | { 371 | $ch = curl_init(); 372 | 373 | curl_setopt($ch, CURLOPT_URL, "https://overcast.fm/login"); 374 | curl_setopt($ch, CURLOPT_POST, 2); 375 | curl_setopt( 376 | $ch, 377 | CURLOPT_POSTFIELDS, 378 | http_build_query(array( 379 | 'email' => $email, 380 | 'password' => $password 381 | )) 382 | ); 383 | curl_setopt($ch, CURLOPT_HEADER, 1); 384 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 385 | 386 | $response = curl_exec($ch); 387 | 388 | $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE); 389 | $header = substr($response, 0, $header_size); 390 | 391 | curl_close($ch); 392 | 393 | preg_match('/Set-Cookie: o=([^;]+);/', $header, $matches); 394 | if (isset($matches[1])) { 395 | return $matches[1]; 396 | } else { 397 | return null; 398 | } 399 | } 400 | 401 | function followRedirects($url) 402 | { 403 | $ch = curl_init(); 404 | 405 | curl_setopt($ch, CURLOPT_URL, $url); 406 | curl_setopt($ch, CURLOPT_NOBODY, 1); 407 | curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1); 408 | curl_setopt($ch, CURLOPT_MAXREDIRS, 5); 409 | curl_setopt($ch, CURLOPT_HEADER, 1); 410 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 411 | 412 | curl_exec($ch); 413 | 414 | $url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); 415 | 416 | curl_close($ch); 417 | 418 | return $url; 419 | } 420 | ?> 421 | -------------------------------------------------------------------------------- /pmap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /setup.php: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /smapi.php: -------------------------------------------------------------------------------- 1 | setClass('Sonos'); 7 | $server->handle(); 8 | ?> 9 | -------------------------------------------------------------------------------- /sonos.php: -------------------------------------------------------------------------------- 1 | username; 13 | $password = $params->password; 14 | 15 | $token = login($username, $password); 16 | if ($token) { 17 | $response = new StdClass(); 18 | $response->getSessionIdResult = $token; 19 | return $response; 20 | } else { 21 | return new SoapFault("Client.LoginInvalid", "Overcast login failed"); 22 | } 23 | } 24 | 25 | function credentials($params) 26 | { 27 | if (isset($params->sessionId)) { 28 | $this->sessionId = $params->sessionId; 29 | } 30 | } 31 | 32 | function getLastUpdate() 33 | { 34 | $start = microtime(true); 35 | 36 | $lastUpdate = getAccountLastUpdate($this->sessionId); 37 | 38 | $response = new StdClass(); 39 | $response->getLastUpdateResult = new StdClass(); 40 | $response->getLastUpdateResult->favorites = $lastUpdate; 41 | $response->getLastUpdateResult->catalog = $lastUpdate; 42 | $response->getLastUpdateResult->pollInterval = 300; 43 | 44 | $duration = microtime(true) - $start; 45 | error_log("SOAP getLastUpdate " . round($duration * 1000) . "ms"); 46 | 47 | return $response; 48 | } 49 | 50 | function getMetadata($params) 51 | { 52 | $start = microtime(true); 53 | 54 | $count = $params->count; 55 | $id = $params->id; 56 | $index = $params->index; 57 | 58 | $total = 0; 59 | $mediaCollection = array(); 60 | $mediaMetadata = array(); 61 | 62 | if ($id == "root") { 63 | $total = 2; 64 | 65 | $media = new StdClass(); 66 | $media->id = "active"; 67 | $media->itemType = "container"; 68 | $media->displayType = ""; 69 | $media->title = "All Active Episodes"; 70 | $media->canPlay = true; 71 | $media->canAddToFavorites = false; 72 | $media->containsFavorite = true; 73 | $mediaCollection[] = $media; 74 | 75 | $media = new StdClass(); 76 | $media->id = "podcasts"; 77 | $media->itemType = "container"; 78 | $media->displayType = ""; 79 | $media->title = "Podcasts"; 80 | $media->canAddToFavorites = false; 81 | $mediaCollection[] = $media; 82 | } elseif ($id == "active") { 83 | $episodeIDs = fetchAccount($this->sessionId)->episodeIDs; 84 | $total = count($episodeIDs); 85 | 86 | foreach (array_slice($episodeIDs, $index, $count) as $episodeID) { 87 | $mediaMetadata[] = $this->findEpisodeMediaMetadata($episodeID, true); 88 | 89 | if (microtime(true) - $start > 1) { 90 | break; 91 | } 92 | } 93 | } elseif ($id == "podcasts") { 94 | $podcastIDs = fetchAccount($this->sessionId)->podcastIDs; 95 | $total = count($podcastIDs); 96 | 97 | foreach (array_slice($podcastIDs, $index, $count) as $podcastID) { 98 | $mediaCollection[] = $this->findPodcastMediaMetadata($podcastID); 99 | 100 | if (microtime(true) - $start > 1) { 101 | break; 102 | } 103 | } 104 | } else { 105 | $podcast = fetchPodcast($id); 106 | $activeEpisodeIDs = fetchAccount($this->sessionId)->episodeIDs; 107 | $total = count($podcast->episodeIDs); 108 | 109 | foreach ( 110 | array_slice($podcast->episodeIDs, $index, $count) 111 | as $episodeID 112 | ) { 113 | $mediaMetadata[] = $this->findEpisodeMediaMetadata( 114 | $episodeID, 115 | in_array($episodeID, $activeEpisodeIDs) 116 | ); 117 | 118 | if (microtime(true) - $start > 1) { 119 | break; 120 | } 121 | } 122 | } 123 | 124 | $response = new StdClass(); 125 | $response->getMetadataResult = new StdClass(); 126 | $response->getMetadataResult->index = $index; 127 | $response->getMetadataResult->total = $total; 128 | $response->getMetadataResult->count = 129 | count($mediaCollection) + count($mediaMetadata); 130 | $response->getMetadataResult->mediaCollection = $mediaCollection; 131 | $response->getMetadataResult->mediaMetadata = $mediaMetadata; 132 | 133 | $duration = microtime(true) - $start; 134 | error_log("SOAP getMetadata " . round($duration * 1000) . "ms"); 135 | 136 | return $response; 137 | } 138 | 139 | function getMediaMetadata($params) 140 | { 141 | $start = microtime(true); 142 | 143 | $id = $params->id; 144 | 145 | $response = new StdClass(); 146 | 147 | $activeEpisodeIDs = fetchAccount($this->sessionId)->episodeIDs; 148 | $response->getMediaMetadataResult = $this->findEpisodeMediaMetadata( 149 | $id, 150 | in_array($id, $activeEpisodeIDs) 151 | ); 152 | 153 | $duration = microtime(true) - $start; 154 | error_log("SOAP getMediaMetadata " . round($duration * 1000) . "ms"); 155 | 156 | return $response; 157 | } 158 | 159 | function getMediaURI($params) 160 | { 161 | $start = microtime(true); 162 | 163 | $id = $params->id; 164 | 165 | $episode = fetchEpisode($id); 166 | if (is_null($episode)) { 167 | return new SoapFault("Client.ItemNotFound", "Episode not found."); 168 | } 169 | 170 | $response = new StdClass(); 171 | $response->getMediaURIResult = followRedirects($episode->url); 172 | 173 | $progress = fetchEpisodeProgress($this->sessionId, $id); 174 | if ($progress) { 175 | $response->positionInformation = new StdClass(); 176 | $response->positionInformation->id = $id; 177 | $response->positionInformation->index = 0; 178 | $response->positionInformation->offsetMillis = $progress->position * 1000; 179 | } 180 | 181 | $duration = microtime(true) - $start; 182 | error_log("SOAP getMediaURI " . round($duration * 1000) . "ms"); 183 | 184 | return $response; 185 | } 186 | 187 | function getExtendedMetadata($params) 188 | { 189 | $start = microtime(true); 190 | 191 | $id = $params->id; 192 | 193 | $response = new StdClass(); 194 | $response->getExtendedMetadataResult = new StdClass(); 195 | 196 | if ($id == "root") { 197 | } elseif ($id == "active") { 198 | } elseif ($id == "podcasts") { 199 | } elseif (substr($id, 0, 1) == '+') { 200 | $activeEpisodeIDs = fetchAccount($this->sessionId)->episodeIDs; 201 | $response->getExtendedMetadataResult->mediaMetadata = $this->findEpisodeMediaMetadata( 202 | $id, 203 | in_array($id, $activeEpisodeIDs) 204 | ); 205 | } else { 206 | $response->getExtendedMetadataResult->mediaCollection = $this->findPodcastMediaMetadata( 207 | $id 208 | ); 209 | } 210 | 211 | $duration = microtime(true) - $start; 212 | error_log("SOAP getExtendedMetadata " . round($duration * 1000) . "ms"); 213 | 214 | return $response; 215 | } 216 | 217 | function createItem($params) 218 | { 219 | $start = microtime(true); 220 | 221 | $id = $params->favorite; 222 | 223 | $response = new StdClass(); 224 | $response->createItemResult = $id; 225 | 226 | addEpisode($this->sessionId, $id); 227 | 228 | $duration = microtime(true) - $start; 229 | error_log("SOAP createItem " . round($duration * 1000) . "ms"); 230 | 231 | return $response; 232 | } 233 | 234 | function deleteItem($params) 235 | { 236 | $start = microtime(true); 237 | 238 | $id = $params->favorite; 239 | 240 | $response = new StdClass(); 241 | 242 | deleteEpisode($this->sessionId, $id); 243 | 244 | $duration = microtime(true) - $start; 245 | error_log("SOAP createItem " . round($duration * 1000) . "ms"); 246 | 247 | return $response; 248 | } 249 | 250 | function setPlayedSeconds($params) 251 | { 252 | $start = microtime(true); 253 | 254 | $id = $params->id; 255 | $offsetMillis = $params->offsetMillis; 256 | 257 | if ($offsetMillis) { 258 | updateEpisodeProgress($this->sessionId, $id, $offsetMillis / 1000); 259 | } 260 | 261 | $response = new StdClass(); 262 | $response->setPlayedSecondsResult = new StdClass(); 263 | 264 | $duration = microtime(true) - $start; 265 | error_log("SOAP setPlayedSeconds " . round($duration * 1000) . "ms"); 266 | 267 | return $response; 268 | } 269 | 270 | function reportPlaySeconds($params) 271 | { 272 | $start = microtime(true); 273 | 274 | $id = $params->id; 275 | $offsetMillis = $params->offsetMillis; 276 | 277 | if ($offsetMillis) { 278 | updateEpisodeProgress($this->sessionId, $id, $offsetMillis / 1000); 279 | } 280 | 281 | $response = new StdClass(); 282 | $response->reportPlaySecondsResult = new StdClass(); 283 | $response->reportPlaySecondsResult->interval = 10; 284 | 285 | $duration = microtime(true) - $start; 286 | error_log("SOAP reportPlaySeconds " . round($duration * 1000) . "ms"); 287 | 288 | return $response; 289 | } 290 | 291 | function reportPlayStatus($params) 292 | { 293 | $start = microtime(true); 294 | 295 | $id = $params->id; 296 | $offsetMillis = $params->offsetMillis; 297 | 298 | if ($offsetMillis) { 299 | updateEpisodeProgress($this->sessionId, $id, $offsetMillis / 1000); 300 | } 301 | 302 | $response = new StdClass(); 303 | $response->reportPlayStatusResult = new StdClass(); 304 | 305 | $duration = microtime(true) - $start; 306 | error_log("SOAP reportPlayStatus " . round($duration * 1000) . "ms"); 307 | 308 | return $response; 309 | } 310 | 311 | function findPodcastMediaMetadata($id) 312 | { 313 | $media = new StdClass(); 314 | $podcast = fetchPodcast($id); 315 | 316 | if (is_null($podcast)) { 317 | $media->id = $id; 318 | $media->itemType = "album"; 319 | $media->displayType = ""; 320 | $media->title = "Podcast not found"; 321 | $media->canPlay = false; 322 | $media->canAddToFavorites = false; 323 | $media->containsFavorite = false; 324 | } else { 325 | $media->id = $podcast->id; 326 | $media->itemType = "album"; 327 | $media->displayType = ""; 328 | $media->title = $podcast->title; 329 | $media->albumArtURI = $podcast->imageURL; 330 | $media->canPlay = true; 331 | $media->canAddToFavorites = false; 332 | $media->containsFavorite = false; 333 | } 334 | 335 | return $media; 336 | } 337 | 338 | function findEpisodeMediaMetadata($id, $favorite) 339 | { 340 | $media = new StdClass(); 341 | $episode = fetchEpisode($id); 342 | 343 | if (is_null($episode)) { 344 | $media->id = $id; 345 | $media->itemType = "track"; 346 | $media->title = "Episode not found"; 347 | $media->mimeType = "audio/mp3"; 348 | $media->displayType = ""; 349 | $media->summary = ""; 350 | $media->trackMetadata = new StdClass(); 351 | $media->trackMetadata->canPlay = false; 352 | } else { 353 | $media->id = $episode->id; 354 | $media->isFavorite = $favorite; 355 | $media->displayType = ""; 356 | $media->mimeType = $episode->mimeType; 357 | $media->itemType = "track"; 358 | $media->title = $episode->title; 359 | $media->summary = ""; 360 | $media->trackMetadata = new StdClass(); 361 | $media->trackMetadata->canPlay = true; 362 | $media->trackMetadata->canAddToFavorites = true; 363 | $media->trackMetadata->albumArtURI = $episode->imageURL; 364 | $media->trackMetadata->albumId = $episode->podcastId; 365 | $media->trackMetadata->album = $episode->podcastTitle; 366 | 367 | if (isset($episode->number)) { 368 | $media->trackMetadata->trackNumber = $episode->number; 369 | } 370 | 371 | if (isset($episode->duration)) { 372 | $media->trackMetadata->canResume = true; 373 | $media->trackMetadata->duration = $episode->duration; 374 | } 375 | } 376 | 377 | return $media; 378 | } 379 | } 380 | ?> 381 | -------------------------------------------------------------------------------- /strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Welcome! This string is displayed in the 'Add Service' wizard. 5 | Star Track 6 | Track has been starred 7 | An example of a SMAPI Custom Error Message. 8 | 9 | 10 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | for filename in *.php; do 6 | php -l "$filename" 7 | done 8 | 9 | curl --fail "http://web/api.php?method=fetchPodcast&id=itunes617416468%2Faccidental-tech-podcast" 10 | echo 11 | --------------------------------------------------------------------------------