├── .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 | [](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 |
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 |
--------------------------------------------------------------------------------