mirror of https://github.com/krateng/maloja.git
Compare commits
871 Commits
Author | SHA1 | Date |
---|---|---|
krateng | 39a42e915c | |
krateng | b8944b4954 | |
krateng | 9d9f3b500e | |
krateng | 72c58509a1 | |
krateng | 11a5cb7401 | |
krateng | b4c8a0d68b | |
krateng | 88403d2583 | |
krateng | 866d4ccd9b | |
FoxxMD | 3db51a94d6 | |
FoxxMD | a9c29f158e | |
krateng | ab8af32812 | |
FoxxMD | 7bc2ba0237 | |
FoxxMD | b8371347b7 | |
FoxxMD | 1e3c6597d4 | |
krateng | 37210995fa | |
Chris Newton | 94ae453133 | |
krateng | 93bbaac0e3 | |
krateng | 00a564c54d | |
krateng | 4330b0294b | |
krateng | b53141f065 | |
krateng | 3ae395f697 | |
krateng | 5466b6c37e | |
krateng | e85861fb79 | |
krateng | a611b78dbc | |
krateng | c3ed5f318d | |
krateng | 073448257a | |
krateng | d12229d8a5 | |
krateng | d8f53a56d2 | |
krateng | c8f9e9c391 | |
krateng | 185a5b3e87 | |
krateng | 95eaf0a3d6 | |
krateng | a7d286c90c | |
krateng | ddc78c5756 | |
krateng | a12253dc29 | |
krateng | 9eaeffca7e | |
krateng | db8389e6c1 | |
krateng | ef06f22622 | |
krateng | b333009684 | |
krateng | ebd78914f9 | |
krateng | 36d0e7bb8a | |
krateng | 91750db8ac | |
krateng | d5f2c254f3 | |
krateng | e3933e7dca | |
Karol Kosek | 9b10ca4a5d | |
Karol Kosek | 2ce2e2f682 | |
krateng | 9917210b66 | |
krateng | 5656f8b4c0 | |
badlandspray | 9ae14da397 | |
badlandspray | 3fd02c1675 | |
badlandspray | f7251c613c | |
badlandspray | d57bf33969 | |
krateng | a1b2261fa7 | |
krateng | 260c587248 | |
badlandspray | c1493255b7 | |
krateng | 97fc38f919 | |
krateng | 397d5e7c13 | |
krateng | 1eaba888c7 | |
krateng | 084c7d5a1e | |
krateng | 515fa69fce | |
krateng | ca30309450 | |
badlandspray | 705f4b4252 | |
krateng | ac498bde73 | |
krateng | f3a04c79b1 | |
krateng | f74d5679eb | |
krateng | 5eb838d5df | |
krateng | 96778709bd | |
krateng | a073930601 | |
krateng | 81f4e35258 | |
krateng | c16919eb1e | |
krateng | e116690640 | |
krateng | 8cb332b9fc | |
krateng | 3ede71fc79 | |
krateng | 77a0a0a41b | |
alim4r | ec02672a2e | |
alim4r | 5941123c52 | |
alim4r | 91a7aeb50d | |
krateng | 20aae955b2 | |
krateng | d83b44de6e | |
krateng | 8197548285 | |
krateng | 6171d1d2e1 | |
krateng | 0c948561a8 | |
krateng | 02c77a5e31 | |
krateng | bfa553bed0 | |
krateng | 3592571afd | |
krateng | c77b7c952f | |
krateng | 8a44d3def2 | |
krateng | cf04583122 | |
krateng | 8845f931df | |
krateng | 9c6c91f594 | |
krateng | 2c31df3c58 | |
krateng | 9c656ee90b | |
krateng | 938947d06c | |
krateng | ac3ca0b5e9 | |
krateng | 64d4036f55 | |
krateng | 6df363a763 | |
krateng | 7062c0b440 | |
krateng | ad50ee866c | |
krateng | 62abc31930 | |
krateng | c55e12dd43 | |
krateng | 3b156a73ff | |
krateng | 5b48c33a79 | |
krateng | 95f98370cf | |
krateng | e470e2e43f | |
krateng | 35f428ef69 | |
krateng | 342b8867d9 | |
krateng | bfc83fdbb0 | |
krateng | f359662cf3 | |
krateng | de286b58b9 | |
krateng | d5f5b48d85 | |
Jiri Travnicek | 00b3e6fc57 | |
Jiri Travnicek | e1074ba259 | |
krateng | 7c77474feb | |
krateng | 279499ad9f | |
krateng | dc1becd683 | |
krateng | b3d4cb7a15 | |
krateng | 4c1ba087ba | |
krateng | 0c94dc845b | |
Jiri Travnicek | 9589a6a5c9 | |
Jiri Travnicek | d54f2f8d35 | |
Jiri Travnicek | 082d11309b | |
krateng | 3cb72f46bc | |
krateng | d81f8374c9 | |
krateng | c86ae31ea9 | |
krateng | c3bb8ad322 | |
krateng | 6c5f08aa5a | |
krateng | 29a6a74c37 | |
krateng | 1bbb600481 | |
krateng | df07307730 | |
krateng | 74977b18cc | |
krateng | 029d0464b4 | |
krateng | db8bf60aef | |
krateng | 52ee456b1f | |
northys | 519c26b8d8 | |
krateng | e330678a05 | |
krateng | 0424fa7795 | |
krateng | 528d3565b7 | |
krateng | 56f7c18495 | |
krateng | 1dfda0086e | |
krateng | 7c9f6e9e2d | |
krateng | 529d0c8a5d | |
krateng | cf4b3cd68f | |
krateng | 9272c191d8 | |
krateng | d0ccf3d1ae | |
krateng | 10fef00592 | |
krateng | 1ed4af10ac | |
krateng | 11bc92ee8f | |
krateng | 98c791064d | |
krateng | d208290956 | |
krateng | 009d77a75e | |
krateng | e6992f1e90 | |
krateng | c52ad81fc2 | |
krateng | f5d1fbc576 | |
krateng | a8f8d86ec1 | |
krateng | e9189b8903 | |
krateng | 01d52d7e36 | |
krateng | 528c954de9 | |
krateng | 7c0ecda8a2 | |
krateng | 6e4e62755d | |
krateng | 646c290a37 | |
krateng | 28163348fa | |
alim4r | 495627f3f7 | |
alim4r | 6893fd745a | |
krateng | 91dae00851 | |
krateng | c0ff50b064 | |
krateng | 884e95dc58 | |
krateng | 8023c2d51c | |
krateng | df6bbebe31 | |
krateng | de625cc3c2 | |
krateng | 428d92a267 | |
krateng | 20092df02c | |
krateng | 713dbc34bb | |
krateng | 181406d339 | |
krateng | 9b5eb6f723 | |
krateng | 662923dd5e | |
krateng | ff71a9c526 | |
krateng | fbbd959295 | |
krateng | ce495176c1 | |
krateng | afc78e75b0 | |
alim4r | 85bb1f36cc | |
alim4r | c457b58ab8 | |
krateng | 62208bf668 | |
krateng | 53bc856222 | |
alim4r | b525252af1 | |
krateng | 43ec4c2c9e | |
krateng | 17be00f794 | |
krateng | fe21894c5e | |
krateng | 2bb3fa12b3 | |
krateng | 32a900cf37 | |
krateng | 397eaf668f | |
northys | 80ba4550c7 | |
krateng | b31e778d95 | |
krateng | 6e8cbe6a57 | |
krateng | 45ea7499b2 | |
krateng | 77c4dac7be | |
krateng | 61526fdc89 | |
krateng | ea6d70a650 | |
krateng | 57e66fdafd | |
krateng | 0d985ff706 | |
krateng | 27a9543da9 | |
krateng | c9d2527a98 | |
krateng | 977385a700 | |
krateng | c8522bd473 | |
krateng | 83e3157ad1 | |
krateng | 0525ff400b | |
krateng | 13856a2347 | |
krateng | e9bf65da34 | |
krateng | 5bf66ab270 | |
krateng | 206ebd58ea | |
krateng | a642c274e3 | |
ICTman1076 | 8ba973ed91 | |
krateng | ca726c774a | |
krateng | 33bbe61ece | |
krateng | 15f815ffe9 | |
krateng | fa2ce0c05f | |
krateng | b806be6e02 | |
krateng | 6601920f69 | |
krateng | f3f7dbd8ef | |
krateng | 263e7cd704 | |
krateng | 5b8e2debbc | |
krateng | bccd88acd4 | |
krateng | 371e73ac99 | |
krateng | c33fcf1dc1 | |
krateng | 98e1926613 | |
krateng | b255d424ee | |
krateng | 28d43d00cb | |
krateng | 7f9aa125af | |
krateng | 1d9247fc72 | |
krateng | c91cae9de1 | |
krateng | 1a977d9c0c | |
krateng | 62a654bfbf | |
krateng | 16d8ed0575 | |
krateng | 7c1d45f4af | |
krateng | 65fd57dceb | |
krateng | 29f722e3d3 | |
krateng | e6bb844ff9 | |
krateng | 4cffc9971d | |
krateng | bcb1d36b4a | |
krateng | 9d8752d052 | |
krateng | 741246a7c1 | |
Daniel Aleksandersen | c076518d76 | |
krateng | 4a8221f7a0 | |
krateng | 42579ad1f0 | |
krateng | ef312c6ba9 | |
krateng | dad1365627 | |
krateng | 1c2062c512 | |
krateng | 6b39ca8b19 | |
krateng | 700b81217c | |
krateng | 50cf592a75 | |
krateng | 0f39ecbf7e | |
krateng | d018a758c0 | |
krateng | f31c95228e | |
krateng | 2cf785faae | |
krateng | 189dfb58bc | |
krateng | cabfa298b9 | |
krateng | b8aa2a562e | |
krateng | cc4d40ae3f | |
krateng | 5a2856a682 | |
krateng | 2d2a7c2ee7 | |
krateng | 6635a9ac50 | |
krateng | df5eb499af | |
krateng | e52f35d65b | |
krateng | 97e1eae386 | |
krateng | e152a2edde | |
krateng | 871b3d289d | |
krateng | abde7e72c4 | |
krateng | 24dfa41ad9 | |
krateng | bceb0db09a | |
krateng | 87f1250629 | |
krateng | bb68afee12 | |
krateng | 233e49d087 | |
krateng | fe727dedee | |
krateng | 64f6836365 | |
krateng | 96933d5f18 | |
krateng | fba21b7128 | |
krateng | 1207475e4d | |
krateng | de5ae6408a | |
krateng | 45d481b1ed | |
krateng | 806f024f51 | |
krateng | 5952b8de4d | |
krateng | 3115d0372b | |
krateng | df996f7cb6 | |
krateng | 42cde8b647 | |
krateng | 9e7bbb6c20 | |
krateng | 28ba7b6ad0 | |
krateng | 0f59ffb288 | |
krateng | 7864c9f897 | |
krateng | 5524c0a70f | |
krateng | dc192d7444 | |
krateng | 4e33f808e4 | |
krateng | 700d99c5ae | |
krateng | 037f195803 | |
krateng | e9d8303763 | |
krateng | 387c40d18c | |
krateng | 40c0edb06f | |
krateng | 9f26cce34b | |
krateng | a142804bfe | |
krateng | 8d111b6de7 | |
krateng | 848f009774 | |
krateng | c9fa9956bb | |
krateng | 2deb5f0e36 | |
krateng | 2c73c81434 | |
krateng | c378c9301d | |
krateng | 74f6a931a4 | |
krateng | c982cbd1c4 | |
krateng | 6b4f2f713b | |
krateng | 4682914b88 | |
krateng | 781ed66357 | |
krateng | 2720dc1be5 | |
krateng | 34db81ccef | |
krateng | 6ca18b4471 | |
krateng | c676e0a5bf | |
krateng | 08bd352641 | |
krateng | de18ecff26 | |
krateng | 36f7ab1670 | |
krateng | 24c65d4acc | |
krateng | 1257768e33 | |
krateng | f4e42f9256 | |
krateng | a16c24281e | |
krateng | bd29c1e1ba | |
krateng | 6fc3a9cbf8 | |
krateng | abe658cc77 | |
krateng | 447d31b44e | |
krateng | ec5723d2b3 | |
krateng | 8ff7acfc38 | |
krateng | 0ae9091889 | |
krateng | e1ce80131a | |
krateng | b7781d27c3 | |
krateng | b41203bac7 | |
krateng | c647a57983 | |
krateng | 1b087e92db | |
krateng | 72b74eb27e | |
krateng | 2748d0e360 | |
krateng | e0af117805 | |
krateng | 153ab41ce7 | |
krateng | acc08693b3 | |
krateng | b510e52188 | |
krateng | ba5b0c8957 | |
krateng | c8f678b600 | |
krateng | 31c6fe6243 | |
krateng | b96f0cfc08 | |
krateng | ca2596cfc9 | |
krateng | c150a57090 | |
krateng | a833039ced | |
krateng | d8821efeeb | |
krateng | 3389d6c5f5 | |
krateng | 8ed3923851 | |
krateng | 608986b239 | |
krateng | 2a1f188e37 | |
krateng | 27cacbf658 | |
krateng | 3275e4ec5d | |
krateng | 5d582d39aa | |
krateng | 3108b368ef | |
krateng | 38f2173bde | |
krateng | e611d05c34 | |
krateng | 97aed7e73c | |
krateng | 634df2ffaf | |
krateng | eea2e917f5 | |
krateng | 04947cb97d | |
krateng | a598ba96de | |
krateng | 9f8e691924 | |
krateng | be4ed055ff | |
krateng | e22ef4d268 | |
krateng | c8ed894efb | |
krateng | e31c0dce57 | |
krateng | 3f098b6993 | |
krateng | fa9fee758c | |
krateng | c0bf8cb8ac | |
krateng | e7663138c1 | |
krateng | 66bd69b49e | |
krateng | fec6686ccc | |
krateng | dad027677e | |
krateng | fce450fac3 | |
krateng | 822895461e | |
krateng | 66d703b623 | |
krateng | 68fd6fe65f | |
krateng | cc24d48e65 | |
krateng | 1fff4eca6c | |
krateng | a443e6250e | |
krateng | c3e6dcd1eb | |
krateng | 634cb38dec | |
krateng | c944a3d937 | |
krateng | cc2b984080 | |
krateng | 34e0b0fd67 | |
krateng | 4dd7cf69a7 | |
krateng | 36b47368a3 | |
krateng | 30e973402b | |
krateng | 4e1b099547 | |
krateng | e3dc401ccf | |
krateng | 7b89d227a3 | |
krateng | c8e658af43 | |
krateng | 2930d40685 | |
krateng | 02e3f17594 | |
krateng | 57142bc327 | |
krateng | e398dd3ac1 | |
krateng | 48d88b208f | |
krateng | 827b05da8f | |
krateng | bdbb644d8e | |
krateng | aff56c9069 | |
krateng | af57103300 | |
krateng | a1ef5a7791 | |
krateng | fe0d06af7e | |
krateng | 86c4261a96 | |
krateng | a4e06413d8 | |
krateng | b83eee559f | |
krateng | 631fd941ec | |
krateng | c952fab440 | |
krateng | 65f3dac40a | |
krateng | 349e0bb7ea | |
krateng | 4cd16d73d3 | |
krateng | 2b75e1e50f | |
krateng | 538daeb284 | |
krateng | d38cf8d4be | |
krateng | 9f862dd89b | |
krateng | 54a73243cc | |
krateng | d3258a7e63 | |
krateng | 0f473599a7 | |
krateng | bde06deb4f | |
krateng | a367c7c573 | |
krateng | 9e4274f209 | |
krateng | dba31867c6 | |
krateng | 6b05dde7c0 | |
krateng | 7b3e1bbaa6 | |
krateng | 900ce51af0 | |
krateng | e980efa731 | |
krateng | 42607cedb7 | |
krateng | deb35ec042 | |
krateng | ef594c2546 | |
krateng | 7e62ddebf6 | |
krateng | f645f73f1f | |
krateng | bfed3604c5 | |
krateng | cf43a9221a | |
krateng | 4c40fb0577 | |
krateng | 765ab493cb | |
krateng | a1f8e96ae4 | |
krateng | cc060d650b | |
krateng | d9f4021342 | |
krateng | b95d1e8b0c | |
krateng | 73564eccc1 | |
krateng | b53df53c40 | |
krateng | fee94a88c5 | |
krateng | 78c50d24d9 | |
krateng | 055dca4b6d | |
krateng | 8db87bdbc5 | |
krateng | a64d3610d3 | |
krateng | 034f8b32c7 | |
krateng | 38e2a184af | |
krateng | d3797d89fa | |
krateng | 4f7e1decd3 | |
krateng | b3002b1578 | |
krateng | efad4379be | |
krateng | b325fab698 | |
krateng | eb9d29686b | |
krateng | b50afe70ea | |
krateng | eb9cd4aba4 | |
krateng | df07dd7b00 | |
krateng | 7021099e7b | |
krateng | 1df51748b6 | |
krateng | 632905a1c7 | |
krateng | 65a076c249 | |
krateng | 6611ca8705 | |
krateng | c120850d42 | |
krateng | 02ddeb4dc0 | |
krateng | f68fe04760 | |
krateng | 11bebce807 | |
krateng | 1824a8e5dc | |
krateng | 8a96a2c144 | |
krateng | 44a124e6ec | |
krateng | 80acf6275f | |
krateng | 40e733a054 | |
krateng | 9fc838e4c8 | |
krateng | 2f7f4c8567 | |
krateng | 03186bc49f | |
krateng | f88852ee6a | |
krateng | 0dd6cd9dd5 | |
krateng | 8ab42b844b | |
krateng | 03dd902e1b | |
krateng | c826b069e4 | |
krateng | 0233adedec | |
krateng | 9eb8dc0b47 | |
krateng | 68a450672e | |
krateng | ccbb3d3a80 | |
krateng | f16334aaac | |
krateng | 9370e62a47 | |
krateng | 7d979e92fe | |
krateng | b5bf87bf6b | |
krateng | 4c9f824bbd | |
krateng | e62c637aa0 | |
krateng | b8fc3db371 | |
krateng | 6ac86d3d19 | |
krateng | 9ec52806c4 | |
krateng | 3714aef878 | |
krateng | eff806bd73 | |
krateng | 21d1643988 | |
krateng | 868b8396a0 | |
krateng | f806fb8ed2 | |
krateng | cd8e0ff90a | |
krateng | fb2dff8add | |
krateng | 1b0e3ffdb2 | |
krateng | f71bd72825 | |
krateng | 1e3ea1fba9 | |
krateng | 57c090bdcd | |
krateng | fe1ed955cd | |
krateng | 37bac06735 | |
krateng | b704aa8092 | |
krateng | 9b23c3bd57 | |
krateng | 83f1956256 | |
krateng | af569ae983 | |
krateng | 03e741c0ae | |
krateng | f56e23db1e | |
krateng | 84e8ac0139 | |
krateng | 6c7e94fc20 | |
krateng | 0ec93d7d0f | |
krateng | 5228a12e3f | |
krateng | cdd762a07a | |
krateng | 61b1271491 | |
krateng | c82936ad4a | |
krateng | 6aee8c7a48 | |
krateng | 115be57651 | |
krateng | 898a9930bb | |
krateng | 2a642c5f80 | |
krateng | 7f650e604e | |
krateng | a1ba5f58b8 | |
krateng | 6fc2c1c889 | |
krateng | 1be4e50b49 | |
krateng | f2dd5862e2 | |
krateng | 5651626c39 | |
krateng | 73a6c18b17 | |
krateng | c745d4a647 | |
krateng | c98fc592a1 | |
krateng | e53588b402 | |
krateng | 9f4041de78 | |
krateng | 0f40ebbad9 | |
krateng | 8661d9f7e4 | |
krateng | 83ad648832 | |
krateng | 6798500398 | |
krateng | 0d2599fb82 | |
krateng | b6551131ba | |
krateng | 06f178b58a | |
krateng | 4aa1343cf1 | |
krateng | 36c8f2654e | |
krateng | 1e70a523b2 | |
krateng | 0ccd39ffd9 | |
krateng | 2b55e3a7c9 | |
krateng | 40648b66f3 | |
krateng | 0f5ccd4645 | |
krateng | cefed03bc9 | |
krateng | 8555b28fbc | |
krateng | e4b63bb570 | |
krateng | 3ee68e75ac | |
krateng | 2fa9eeaa07 | |
krateng | 72941558d2 | |
krateng | 14938d8fb5 | |
krateng | 5ba455dc92 | |
krateng | ca78463989 | |
krateng | 7299c2e07e | |
krateng | 97e8d5b18d | |
krateng | e94607dc2c | |
krateng | e006a10f70 | |
krateng | ef04c98ea7 | |
krateng | b212e6b921 | |
krateng | 77667e7066 | |
krateng | 5a2ec61d66 | |
krateng | ba96c8e14f | |
krateng | 1ec283f885 | |
krateng | df22a595b8 | |
krateng | 6ccbf68923 | |
krateng | 4186171b8f | |
krateng | b39e2b889a | |
krateng | ed309dd36e | |
FoxxMD | 27cc4171fe | |
krateng | de05090d7d | |
krateng | b5999dab1f | |
krateng | 54ad3f6d3b | |
krateng | 647e942a35 | |
krateng | 7774d9a936 | |
krateng | 3d573b10f9 | |
krateng | a24e26678a | |
krateng | c0c474b473 | |
krateng | 319519b7e2 | |
FoxxMD | a988f0e6dc | |
krateng | 1303891a2d | |
krateng | 1a689d3a1c | |
krateng | 8958eb1b54 | |
krateng | e597ba8504 | |
krateng | 975e57ea46 | |
FoxxMD | e582e5abe2 | |
krateng | d65def2ddf | |
FoxxMD | a71086c10d | |
krateng | 09e484021c | |
krateng | 383c24511f | |
krateng | 98ec5885e7 | |
krateng | 86f8d5a3eb | |
krateng | a85ec372f2 | |
krateng | 05460f97b7 | |
krateng | 3b8723790a | |
krateng | acace4d04a | |
krateng | 59eaa2264a | |
krateng | a52c494e4b | |
krateng | d23da91101 | |
krateng | 56eaa8a793 | |
krateng | e17002299b | |
krateng | e110ed765f | |
krateng | e901ca719f | |
krateng | 83063ba943 | |
krateng | b4230c0ae6 | |
krateng | 6d5b306d93 | |
krateng | a41aa0962b | |
krateng | 898dd9735c | |
krateng | 2101223440 | |
krateng | be7780a0c0 | |
krateng | ff8e5ec8ff | |
krateng | e236cce86c | |
krateng | d9cd546952 | |
krateng | 29f8c10167 | |
krateng | 26dfdfb569 | |
krateng | 817d98e467 | |
krateng | 504bfb405e | |
krateng | 9b98b3db3c | |
krateng | 33af60ed2c | |
krateng | 5157ce825e | |
Brian Pepple | d1b598a32b | |
krateng | c31770a34c | |
krateng | d3e45b138b | |
krateng | 08cc32ac33 | |
krateng | 4b49ffb3cb | |
krateng | f50728d2a6 | |
krateng | a55343f442 | |
krateng | 01fbc8c3d3 | |
krateng | 9037403777 | |
krateng | 49598e914f | |
krateng | 738a2d84e5 | |
krateng | 79716debf8 | |
krateng | 947614ddcf | |
Timur Demin | ced5e54074 | |
krateng | 8ebd27ab76 | |
krateng | 5455abd0d1 | |
krateng | a652a22a96 | |
krateng | e664a93ef0 | |
krateng | 1727e0c92d | |
krateng | 1aeb72fd8f | |
Krateng | 0cfdc60111 | |
Krateng | 2903a88096 | |
krateng | b75b643f6b | |
krateng | 642cc7c00b | |
Krateng | 6199fc8204 | |
Krateng | 23cc1ac341 | |
Krateng | c2f8ecc2df | |
Krateng | 6555eea887 | |
Krateng | f67f900dae | |
Krateng | 49b0a57581 | |
Krateng | 06c32e205e | |
Krateng | 2a5d9498d1 | |
Krateng | 5006ad2bf1 | |
Krateng | bcd62c16fa | |
Krateng | c4a9c6dc0f | |
Krateng | a42ed56d2d | |
Krateng | 6efe4a48c7 | |
Krateng | 5bfd1e49dd | |
Krateng | c86f3597fd | |
Krateng | 723efcb8ba | |
Krateng | adfd6d2fc2 | |
Krateng | b53052c818 | |
Krateng | 4d06a327e9 | |
Krateng | ac8abd4cb6 | |
Krateng | f5f73d9223 | |
Krateng | db80e1791a | |
Krateng | f44c3fecb2 | |
Krateng | 0818dccac4 | |
Krateng | fe2b0dd7c8 | |
Krateng | ce17f77cfd | |
Krateng | 5fe4c5cd22 | |
Krateng | 218313f80c | |
Krateng | 4e69a6119d | |
krateng | 8b5254e4c7 | |
Krateng | 64bb235011 | |
Krateng | 772f207b7e | |
Krateng | f6b9f2b9fa | |
Krateng | 212fbf368e | |
Krateng | f62fd254dd | |
Krateng | 7a3ee26c87 | |
Krateng | 3de4059f85 | |
FoxxMD | 75d8251a29 | |
Krateng | b9feebb064 | |
Krateng | 74dc54862e | |
Krateng | e6535eb6bc | |
Krateng | 951f6bb562 | |
Krateng | e475b0c716 | |
Krateng | e1c9d525f5 | |
FoxxMD | c87dc32455 | |
krateng | 4304aab8b7 | |
FoxxMD | 5fa1396ddc | |
krateng | 58050513af | |
krateng | 9cdc2cf103 | |
FoxxMD | bd2a3d46ae | |
Krateng | 034bd064f1 | |
Krateng | 755567549c | |
Krateng | b84712ef22 | |
Krateng | 3cf0dd9767 | |
Krateng | cfec8e089b | |
Krateng | c216aa5f24 | |
Krateng | b38267a09f | |
Krateng | df6ad4f073 | |
Krateng | 5b7d1fd8e9 | |
Krateng | 9a6617b4b1 | |
Krateng | e70cb3e037 | |
krateng | 8a0d15d86e | |
Krateng | ac29f9728e | |
Krateng | 79d7af5025 | |
Krateng | f555ee9d9f | |
Krateng | 663c6a0372 | |
krateng | 1940f62260 | |
Krateng | 724bfd7164 | |
Krateng | e8c19a05e4 | |
Krateng | 8cf93c3100 | |
Krateng | 5c2928e13b | |
krateng | 27c65703bc | |
krateng | 0f10f278b5 | |
Krateng | cba9d839a2 | |
Krateng | ce9d882856 | |
Krateng | c125cbc20d | |
Krateng | 2705c524f9 | |
Krateng | 54c3eb9cc0 | |
Krateng | 9f63504633 | |
Krateng | 9e03a01e70 | |
Krateng | 90d6e67e97 | |
krateng | 21b27d17c5 | |
krateng | 38d4716dc5 | |
krateng | 05fca5c7c0 | |
ICTman1076 | a4722f9e55 | |
ICTman1076 | fcee13214b | |
ICTman1076 | 258b9c22d6 | |
ICTman1076 | 30f10ba46e | |
Павло | 951c192adb | |
Krateng | cbd93d2b33 | |
Krateng | 22172d8b57 | |
Krateng | c312608f2d | |
Krateng | f7861c44b4 | |
Krateng | 85f19a8b72 | |
Krateng | 13f2746171 | |
Krateng | 616c24fb5b | |
krateng | b9d9f0ff03 | |
Krateng | f0cbe33f6a | |
Zack Boehm | 026415c7d8 | |
Krateng | 5e93eb15f4 | |
Krateng | 98830b0803 | |
Krateng | 8250c7b467 | |
Krateng | 6dd3fe5512 | |
Krateng | eb9763eb65 | |
Krateng | 6466b48796 | |
Krateng | 336e36fb79 | |
Krateng | e0b990578e | |
Krateng | 2da9f154be | |
Krateng | 6316e45265 | |
Johannes Krattenmacher | b4d224fb66 | |
Krateng | 60a06efad8 | |
Krateng | a462faf2bf | |
Krateng | 160e393a00 | |
Krateng | f4a563f080 | |
Krateng | c1a4d5a4ee | |
Krateng | 9fb352cc6f | |
Krateng | 09d3f10383 | |
Krateng | a0a8e5bcc8 | |
Krateng | e8a87cb8a5 | |
Krateng | 7c8b0dd7cb | |
Krateng | e8c316f199 | |
Krateng | 5cf7ca2e9b | |
Krateng | 52a9faae90 | |
Krateng | 8b4e9609e9 | |
Krateng | 9d3ffae45c | |
Krateng | 35616212ff | |
Krateng | e4139369fe | |
Krateng | 19c928773d | |
Krateng | 03c3202cdf | |
Krateng | 79544044be | |
Krateng | 5a548f1979 | |
Krateng | 785e6cfa17 | |
Krateng | 0b54999a1c | |
Krateng | dad75dbbc2 | |
Krateng | 879b3cf170 | |
Krateng | a4812a66da | |
Krateng | f61804b095 | |
Krateng | 8acf2ef503 | |
Krateng | a6088ec7b7 | |
Krateng | ed1c595e20 | |
Krateng | 26f6a1af58 | |
Krateng | 7bc70ed0bd | |
Krateng | f7f1b1225e | |
Krateng | 10f636e7ed | |
Krateng | 01e555172f | |
Krateng | 6acab324db | |
Krateng | e27a83bdc9 | |
Krateng | 1321fcb45e | |
Krateng | 25661f82af | |
Krateng | 4a811932ac | |
Krateng | 6ca88685bf | |
Krateng | f7ca7b0dc9 | |
Krateng | 363c431b93 | |
krateng | d85d13ce5a | |
Krateng | 316ad48ae6 | |
ICTman1076 | d788b14190 | |
Krateng | b4f84625bc | |
Krateng | e5536ba384 | |
Krateng | d3c3c1fc4c | |
Krateng | e55975514f | |
Krateng | 22ee6bf751 | |
Krateng | a5edc113c8 | |
Krateng | 65861d4c41 | |
Krateng | 44a2739a3b | |
Krateng | c6adc90d4b | |
Krateng | a45696ab62 | |
Krateng | 0abf2aae39 | |
Krateng | 7fc879f778 | |
Krateng | bc5f11d499 | |
Krateng | fddbfb6a41 | |
Krateng | fb04dd507c | |
Krateng | 75bd823ad0 | |
Krateng | 3a4769cfb2 | |
Krateng | 379ee49f1c | |
Krateng | 47087b4288 | |
Krateng | fa05c40660 | |
Krateng | 439d12d87f | |
Krateng | 3e6bcc45d5 | |
Krateng | 7693ba3a20 | |
Krateng | 1563a15abd | |
Krateng | 4113d1761e | |
Krateng | 0127a25ce5 | |
Krateng | 6885fbdecc | |
Krateng | 8d7fb9a2c8 | |
Krateng | 833048440c | |
Krateng | 7f3b7031ac | |
Krateng | 2484015261 | |
Krateng | 94794bff5b | |
Krateng | 6050e26f7a | |
Krateng | ae0da83a9c | |
Krateng | 9a1bc8be03 | |
Krateng | a88afe40ec | |
Krateng | a103c360d3 | |
Krateng | 3e1331b0e3 | |
Krateng | cb7a6d2241 | |
Krateng | bdfb2a4a0b | |
Krateng | ddbdc7ec56 | |
Krateng | 0bdc4654bf | |
Krateng | 0ee6e761da | |
Krateng | 87cdb9987e | |
Krateng | 0fdd7669cc | |
Krateng | 02e41ccbe0 | |
Krateng | 7a4df06090 | |
Krateng | c44e14d0a6 | |
Krateng | ba701a2317 | |
Krateng | b5b09c4052 | |
Krateng | 0ddb5a4dd9 | |
Krateng | a0a8ba4052 | |
Krateng | 2c754c75ce | |
Krateng | 56cc06d905 | |
Krateng | 5f8e73e6c7 | |
Krateng | 27f3ff6d08 | |
Krateng | dd3c83920b | |
Krateng | 1eae55e3bb | |
Krateng | b161da1c1a | |
Krateng | 331374e35c | |
Krateng | 29f88539b4 | |
Krateng | fe106a3227 | |
Krateng | b6a66ff2ed | |
Krateng | 4c30ff5fa2 | |
Krateng | a097d34f10 | |
Krateng | f89dcf0599 | |
Krateng | abef221435 | |
Krateng | d911a7a8c4 | |
Krateng | 8659f98935 | |
Krateng | bda279d01d | |
Krateng | 471a61f788 |
|
@ -0,0 +1,8 @@
|
|||
*
|
||||
!maloja
|
||||
!container
|
||||
!Containerfile
|
||||
!requirements.txt
|
||||
!pyproject.toml
|
||||
!README.md
|
||||
!LICENSE
|
|
@ -0,0 +1 @@
|
|||
custom: ["https://flattr.com/@Krateng", "https://paypal.me/krateng"]
|
|
@ -0,0 +1,36 @@
|
|||
name: Publish library to PyPI
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'auxiliary/malojalib/pyproject.toml'
|
||||
# When the version updates, this file changes
|
||||
# False positives only result in a failed push
|
||||
|
||||
jobs:
|
||||
publish_to_pypi:
|
||||
name: Push Library to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@7f80679172b057fc5e90d70d197929d454754a5a
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install build
|
||||
|
||||
- name: Change directory
|
||||
run: cd auxiliary/malojalib
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@717ba43cfbb0387f6ce311b169a825772f54d295
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@ -0,0 +1,21 @@
|
|||
name: Publish Chromium scrobbler to web store
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'auxiliary/chromium_scrobbler/maloja-scobbler/manifest.json'
|
||||
# When the version updates, this file changes
|
||||
|
||||
jobs:
|
||||
publish_to_pypi:
|
||||
name: Build and publish extension
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Push Extension to Web Store
|
||||
uses: Klemensas/chrome-extension-upload-action@1e8ede84548583abf1a2a495f4242c4c51539337
|
||||
with:
|
||||
refresh-token: '${{ secrets.GOOGLE_REFRESHTOKEN }}'
|
||||
client-id: '${{ secrets.GOOGLE_CLIENTID }}'
|
||||
file-name: './auxiliary/chromium_scrobbler/maloja-scrobbler.zip'
|
||||
app-id: 'cfnbifdmgbnaalphodcbandoopgbfeeh'
|
||||
publish: true
|
|
@ -0,0 +1,76 @@
|
|||
name: Build and release docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@dd4fa0671be5250ee6f50aedf4cb05514abda2c7
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@f2a13332ac1ce8c0a71aeac48a150dbb1838ab67
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository_owner }}/maloja
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
flavor: |
|
||||
latest=true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@27d0a4f181a40b142cce983c5393082c365d1480
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@94ab11c41e45d028884a99163086648e898eed25
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@48af2dc4a9e8278b89d7fa154b955c30c6aaab09
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a
|
||||
with:
|
||||
context: .
|
||||
file: Containerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
|
||||
# Temp fix
|
||||
# https://github.com/docker/build-push-action/issues/252
|
||||
# https://github.com/moby/buildkit/issues/1896
|
||||
- name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
- name: Update Readme and short description
|
||||
uses: peter-evans/dockerhub-description@836d7e6aa8f6f32dce26f5a1dd46d3dc24997eae
|
||||
continue-on-error: true
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }}
|
||||
repository: krateng/maloja
|
||||
short-description: ${{ github.event.repository.description }}
|
|
@ -0,0 +1,31 @@
|
|||
name: Publish to PyPI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish_to_pypi:
|
||||
name: Push Package to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@7f80679172b057fc5e90d70d197929d454754a5a
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install build
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@717ba43cfbb0387f6ce311b169a825772f54d295
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@ -1,13 +1,14 @@
|
|||
# generic temporary / dev files
|
||||
# temporary / generated files
|
||||
*.pyc
|
||||
*.sh
|
||||
!/install_*.sh
|
||||
*.note
|
||||
|
||||
# environments / builds
|
||||
.venv/*
|
||||
testdata*
|
||||
/dist
|
||||
/build
|
||||
/*.egg-info
|
||||
|
||||
# dev files
|
||||
*.xcf
|
||||
nohup.out
|
||||
|
||||
# currently not using
|
||||
/screenshot*.png
|
||||
|
||||
# only for development, normally external
|
||||
/doreah
|
||||
*.note
|
||||
*-old
|
||||
|
|
|
@ -0,0 +1,173 @@
|
|||
# Scrobbling
|
||||
|
||||
Scrobbling can be done with the native API, see [below](#submitting-a-scrobble).
|
||||
In order to scrobble from a wide selection of clients, you can also use Maloja's standard-compliant APIs with the following settings:
|
||||
|
||||
GNU FM |
|
||||
------ | ---------
|
||||
Gnukebox URL | Your Maloja URL followed by `/apis/audioscrobbler`
|
||||
Username | Doesn't matter
|
||||
Password | Any of your API keys
|
||||
|
||||
ListenBrainz |
|
||||
------ | ---------
|
||||
API URL | Your Maloja URL followed by `/apis/listenbrainz`
|
||||
Username | Doesn't matter
|
||||
Auth Token | Any of your API keys
|
||||
|
||||
Audioscrobbler v1.2 |
|
||||
------ | ---------
|
||||
Server URL | Your Maloja URL followed by `/apis/audioscrobbler_legacy`
|
||||
Username | Doesn't matter
|
||||
Password | Any of your API keys
|
||||
|
||||
| :warning: | Note that these are the base URLs - some scrobblers ask you for the full endpoint instead. |
|
||||
|---------------|:------------------------|
|
||||
|
||||
## Scrobbling Guideline
|
||||
|
||||
Maloja makes no assumptions about scrobbling behaviour. The clients should decide when and whether a play is scrobbled - the server will accept it as long as it contains all necessary data. However, a general guideline is:
|
||||
|
||||
* As soon as a track has been played for 50% of its length or 4 minutes, it should be counted as a scrobble
|
||||
* That scrobble should be submitted when the play has ended in order to know its duration
|
||||
* If the total play duration is enough to count as a scrobble, but not longer than the total track length + enough for a second scrobble, it should be submitted as a scrobble with the according duration
|
||||
* If the duration exceeds this value, the first scrobble should be submitted as a scrobble with the duration of the full track length, while the second scrobble is queued up following the above suggestions in regards to remaining time
|
||||
|
||||
|
||||
<table>
|
||||
<tr><td>:memo: Example </td><tr>
|
||||
<tr><td>
|
||||
|
||||
The user starts playing '(Fine Layers of) Slaysenflite', which is exactly 3:00 minutes long.
|
||||
* If the user ends the play after 1:22, no scrobble is submitted
|
||||
* If the user ends the play after 2:06, a scrobble with `"duration":126` is submitted
|
||||
* If the user jumps back several times and ends the play after 3:57, a scrobble with `"duration":237` is submitted
|
||||
* If the user jumps back several times and ends the play after 4:49, two scrobbles with `"duration":180` and `"duration":109` are submitted
|
||||
|
||||
</td></tr>
|
||||
<table>
|
||||
|
||||
|
||||
# API Documentation
|
||||
|
||||
The native Maloja API is reachable at `/apis/mlj_1`. Endpoints are listed on `/api_explorer`.
|
||||
|
||||
All endpoints return JSON data. POST request can be made with query string or form data arguments, but this is discouraged - JSON should be used whenever possible.
|
||||
|
||||
No application should ever rely on the non-existence of fields in the JSON data - i.e., additional fields can be added at any time without this being considered a breaking change. Existing fields should usually not be removed or changed, but it is always a good idea to add basic handling for missing fields.
|
||||
|
||||
## Submitting a Scrobble
|
||||
|
||||
The POST endpoint `/newscrobble` is used to submit new scrobbles. These use a flat JSON structure with the following fields:
|
||||
|
||||
| Key | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `artists` | List(String) | Track artists |
|
||||
| `title` | String | Track title |
|
||||
| `album` | String | Name of the album (Optional) |
|
||||
| `albumartists` | List(String) | Album artists (Optional) |
|
||||
| `duration` | Integer | How long the song was listened to in seconds (Optional) |
|
||||
| `length` | Integer | Actual length of the full song in seconds (Optional) |
|
||||
| `time` | Integer | Timestamp of the listen if it was not at the time of submitting (Optional) |
|
||||
| `nofix` | Boolean | Skip server-side metadata fixing (Optional) |
|
||||
|
||||
## General Structure
|
||||
|
||||
The API is not fully consistent in order to ensure backwards-compatibility. Refer to the individual endpoints.
|
||||
Generally, most endpoints follow this structure:
|
||||
|
||||
| Key | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `status` | String | Status of the request. Can be `success`, `ok`, `error`, `failure`, `no_operation` |
|
||||
| `error` | Mapping | Details about the error if one occured. |
|
||||
| `warnings` | List | Any warnings that did not result in failure, but should be noted. Field is omitted if there are no warnings! |
|
||||
| `desc` | String | Human-readable feedback. This can be shown directly to the user if desired. |
|
||||
| `list` | List | List of returned [entities](#entity-structure) |
|
||||
|
||||
|
||||
Both errors and warnings have the following structure:
|
||||
|
||||
| Key | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `type` | String | Name of the error or warning type |
|
||||
| `value` | varies | Specific data for this error or warning instance |
|
||||
| `desc` | String | Human-readable error or warning description. This can be shown directly to the user if desired. |
|
||||
|
||||
|
||||
## Entity Structure
|
||||
|
||||
Whenever a list of entities is returned, they have the following fields:
|
||||
|
||||
### Scrobble
|
||||
|
||||
| Key | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `time` | Integer | Timestamp of the Scrobble in UTC |
|
||||
| `track` | Mapping | The [track](#track) being scrobbled |
|
||||
| `duration` | Integer | How long the track was played for in seconds |
|
||||
| `origin` | String | Client that submitted the scrobble, or import source |
|
||||
|
||||
|
||||
<table>
|
||||
<tr><td>:memo: Example </td><tr>
|
||||
<tr><td>
|
||||
|
||||
```json
|
||||
{
|
||||
"time": 1650684324,
|
||||
"track": {
|
||||
"artists": ["Jennie Kim","HyunA","LE","SunMi"],
|
||||
"title": "Wow Thing",
|
||||
"length":200
|
||||
},
|
||||
"duration": 196,
|
||||
"origin": "client:navidrome_desktop"
|
||||
}
|
||||
```
|
||||
|
||||
</tr></td>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
### Track
|
||||
|
||||
| Key | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `artists` | List | The [artists](#artist) credited with the track |
|
||||
| `title` | String | The title of the track |
|
||||
| `length` | Integer | The full length of the track in seconds |
|
||||
|
||||
<table>
|
||||
<tr><td>:memo: Example </td><tr>
|
||||
<tr><td>
|
||||
|
||||
```json
|
||||
{
|
||||
"artists": ["Blackpink","Chou Tzuyu"],
|
||||
"title": "MORE",
|
||||
"length": 171
|
||||
}
|
||||
```
|
||||
|
||||
</tr></td>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
### Artist
|
||||
|
||||
Artists are just represented as raw Strings.
|
||||
|
||||
**Example**
|
||||
|
||||
<table>
|
||||
<tr><td>:memo: Example </td><tr>
|
||||
<tr><td>
|
||||
|
||||
```json
|
||||
"Red Velvet"
|
||||
```
|
||||
|
||||
</tr></td>
|
||||
</table>
|
|
@ -0,0 +1,36 @@
|
|||
# Contributor: Johannes Krattenmacher <maloja@dev.krateng.ch>
|
||||
# Maintainer: Johannes Krattenmacher <maloja@dev.krateng.ch>
|
||||
pkgname=maloja
|
||||
pkgver=3.0.0-dev
|
||||
pkgrel=0
|
||||
pkgdesc="Self-hosted music scrobble database"
|
||||
url="https://github.com/krateng/maloja"
|
||||
arch="noarch"
|
||||
license="GPL-3.0"
|
||||
depends="python3 tzdata"
|
||||
pkgusers=$pkgname
|
||||
pkggroups=$pkgname
|
||||
depends_dev="gcc g++ python3-dev libxml2-dev libxslt-dev libffi-dev libc-dev py3-pip linux-headers"
|
||||
makedepends="$depends_dev"
|
||||
source="
|
||||
$pkgname-$pkgver.tar.gz::https://github.com/krateng/maloja/archive/refs/tags/v$pkgver.tar.gz
|
||||
"
|
||||
builddir="$srcdir"/$pkgname-$pkgver
|
||||
|
||||
|
||||
|
||||
build() {
|
||||
cd $builddir
|
||||
python3 -m build .
|
||||
pip3 install dist/*.tar.gz
|
||||
}
|
||||
|
||||
package() {
|
||||
mkdir -p /etc/$pkgname || return 1
|
||||
mkdir -p /var/lib/$pkgname || return 1
|
||||
mkdir -p /var/cache/$pkgname || return 1
|
||||
mkdir -p /var/logs/$pkgname || return 1
|
||||
}
|
||||
|
||||
# TODO
|
||||
sha512sums="a674eaaaa248fc2b315514d79f9a7a0bac6aa1582fe29554d9176e8b551e8aa3aa75abeebdd7713e9e98cc987e7bd57dc7a5e9a2fb85af98b9c18cb54de47bf7 $pkgname-${pkgver}.tar.gz"
|
|
@ -0,0 +1,74 @@
|
|||
FROM lsiobase/alpine:3.17 as base
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
|
||||
|
||||
COPY --chown=abc:abc ./requirements.txt ./requirements.txt
|
||||
|
||||
# based on https://github.com/linuxserver/docker-pyload-ng/blob/main/Dockerfile
|
||||
# everything but the app installation is run in one command so we can purge
|
||||
# all build dependencies and cache in the same layer
|
||||
# it may be possible to decrease image size slightly by using build stage and
|
||||
# copying all site-packages to runtime stage but the image is already pretty small
|
||||
RUN \
|
||||
echo "**** install build packages ****" && \
|
||||
apk add --no-cache --virtual=build-deps \
|
||||
gcc \
|
||||
g++ \
|
||||
python3-dev \
|
||||
libxml2-dev \
|
||||
libxslt-dev \
|
||||
libffi-dev \
|
||||
libc-dev \
|
||||
py3-pip \
|
||||
linux-headers && \
|
||||
echo "**** install runtime packages ****" && \
|
||||
apk add --no-cache \
|
||||
python3 \
|
||||
py3-lxml \
|
||||
tzdata && \
|
||||
echo "**** install pip dependencies ****" && \
|
||||
python3 -m ensurepip && \
|
||||
pip3 install -U --no-cache-dir \
|
||||
pip \
|
||||
wheel && \
|
||||
echo "**** install maloja requirements ****" && \
|
||||
pip3 install --no-cache-dir -r requirements.txt && \
|
||||
echo "**** cleanup ****" && \
|
||||
apk del --purge \
|
||||
build-deps && \
|
||||
rm -rf \
|
||||
/tmp/* \
|
||||
${HOME}/.cache
|
||||
|
||||
# actual installation in extra layer so we can cache the stuff above
|
||||
|
||||
COPY --chown=abc:abc . .
|
||||
|
||||
RUN \
|
||||
echo "**** install maloja ****" && \
|
||||
apk add --no-cache --virtual=install-deps \
|
||||
py3-pip && \
|
||||
pip3 install /usr/src/app && \
|
||||
apk del --purge \
|
||||
install-deps && \
|
||||
rm -rf \
|
||||
/tmp/* \
|
||||
${HOME}/.cache
|
||||
|
||||
|
||||
|
||||
COPY container/root/ /
|
||||
|
||||
ENV \
|
||||
# Docker-specific configuration
|
||||
MALOJA_SKIP_SETUP=yes \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
# Prevents breaking change for previous container that ran maloja as root
|
||||
# On linux hosts (non-podman rootless) these variables should be set to the
|
||||
# host user that should own the host folder bound to MALOJA_DATA_DIRECTORY
|
||||
PUID=0 \
|
||||
PGID=0
|
||||
|
||||
EXPOSE 42010
|
|
@ -0,0 +1,58 @@
|
|||
# Development
|
||||
|
||||
Clone the repository and enter it.
|
||||
|
||||
```console
|
||||
git clone https://github.com/krateng/maloja
|
||||
cd maloja
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
To avoid cluttering your system, consider using a [virtual environment](https://docs.python.org/3/tutorial/venv.html).
|
||||
|
||||
Your system needs several packages installed. For supported distributions, this can be done with e.g.
|
||||
|
||||
```console
|
||||
sh ./install/install_dependencies_alpine.sh
|
||||
```
|
||||
|
||||
For other distros, try to find the equivalents of the packages listed or simply check your error output.
|
||||
|
||||
Then install all Python dependencies with
|
||||
|
||||
```console
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
|
||||
## Running the server
|
||||
|
||||
For development, you might not want to install maloja files all over your filesystem. Use the environment variable `MALOJA_DATA_DIRECTORY` to force all user files into one central directory - this way, you can also quickly change between multiple configurations.
|
||||
|
||||
You can quickly run the server with all your local changes with
|
||||
|
||||
```console
|
||||
python3 -m maloja run
|
||||
```
|
||||
|
||||
You can also build the package with
|
||||
|
||||
```console
|
||||
pip install .
|
||||
```
|
||||
|
||||
|
||||
## Docker
|
||||
|
||||
You can also always build and run the server with
|
||||
|
||||
```console
|
||||
sh ./dev/run_docker.sh
|
||||
```
|
||||
|
||||
This will use the directory `testdata`.
|
||||
|
||||
## Further help
|
||||
|
||||
Feel free to [ask](https://github.com/krateng/maloja/discussions) if you need some help!
|
19
Dockerfile
19
Dockerfile
|
@ -1,19 +0,0 @@
|
|||
FROM python:3-alpine
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
gcc \
|
||||
libxml2-dev \
|
||||
libxslt-dev \
|
||||
py3-pip \
|
||||
libc-dev \
|
||||
linux-headers \
|
||||
&& \
|
||||
pip3 install psutil && \
|
||||
pip3 install malojaserver && \
|
||||
apk del .build-deps
|
||||
|
||||
EXPOSE 42010
|
||||
|
||||
ENTRYPOINT maloja run
|
|
@ -0,0 +1,11 @@
|
|||
Если вы обнаружили этот репозиторий как часть GitHub Arctic Code Vault, я хотел бы искренне извиниться за то, что вы посвятили свой взгляд этому ужасному коду.
|
||||
|
||||
如果您已将该存储库作为GitHub Arctic Code Vault的一部分发现,我谨诚挚地道歉,因为您将目光投向了这一可怕的代码。
|
||||
|
||||
Hoc si repositum partem GitHub arcticum Codicis Buy nudatus, subdens excusare velim simpliciter gravissimum intueri codice.
|
||||
|
||||
Εάν έχετε αποκαλύψει αυτό το αποθετήριο ως μέρος του GitHub Arctic Code Vault, θα ήθελα ειλικρινά να ζητήσω συγνώμη για την υποβολή των ματιών σας σε αυτόν τον τρομερό κώδικα.
|
||||
|
||||
Եթե դուք հայտնաբերել եք այս պահեստը որպես GitHub Arctic Code Vault- ի մաս, ապա ես կցանկանայի անկեղծորեն ներողություն խնդրել ձեր աչքերը այս սարսափելի կոդին ենթարկելու համար:
|
||||
|
||||
If you have uncovered this repository as part of the GitHub Arctic Code Vault, I would like to sincerely apologize for subjecting your eyes to this terrible code.
|
203
README.md
203
README.md
|
@ -1,147 +1,190 @@
|
|||
# Maloja
|
||||
|
||||
[![](https://img.shields.io/pypi/v/malojaserver?style=for-the-badge)](https://pypi.org/project/malojaserver/)
|
||||
[![](https://img.shields.io/pypi/dm/malojaserver?style=for-the-badge)](https://pypi.org/project/malojaserver/)
|
||||
[![](https://img.shields.io/github/stars/krateng/maloja?style=for-the-badge&color=purple)](https://github.com/krateng/maloja/stargazers)
|
||||
[![](https://img.shields.io/github/v/tag/krateng/maloja?label=GitHub&style=for-the-badge&logo=github&logoColor=white)](https://github.com/krateng/maloja)
|
||||
[![](https://img.shields.io/pypi/v/malojaserver?label=PyPI&style=for-the-badge&logo=pypi&logoColor=white)](https://pypi.org/project/malojaserver/)
|
||||
[![](https://img.shields.io/docker/v/krateng/maloja?label=Dockerhub&style=for-the-badge&logo=docker&logoColor=white)](https://hub.docker.com/r/krateng/maloja)
|
||||
|
||||
[![](https://img.shields.io/pypi/l/malojaserver?style=for-the-badge)](https://github.com/krateng/maloja/blob/master/LICENSE)
|
||||
[![](https://img.shields.io/codeclimate/maintainability/krateng/maloja?style=for-the-badge)](https://codeclimate.com/github/krateng/maloja)
|
||||
|
||||
Simple self-hosted music scrobble database to create personal listening statistics. No recommendations, no social network, no nonsense.
|
||||
|
||||
You can check [my own Maloja page](https://maloja.krateng.ch) to see what it looks like.
|
||||
![screenshot](https://raw.githubusercontent.com/krateng/maloja/master/screenshot.png)
|
||||
|
||||
You can check [my own Maloja page](https://maloja.krateng.ch) as an example instance.
|
||||
|
||||
> **IMPORTANT**: With the update 2.0, Maloja has been refactored into a Python package and the old update script no longer works. If you're still on version 1, see [below](#update).
|
||||
|
||||
## Table of Contents
|
||||
* [Why not Last.fm / Libre.fm / GNU FM?](#why-not-lastfm--librefm--gnu-fm)
|
||||
* [Features](#features)
|
||||
* [How to install](#how-to-install)
|
||||
* [Environment](#environment)
|
||||
* [New Installation](#new-installation)
|
||||
* [Update](#update)
|
||||
* [Docker](#docker)
|
||||
* [Requirements](#requirements)
|
||||
* [PyPI](#pypi)
|
||||
* [From Source](#from-source)
|
||||
* [Docker / Podman](#docker--podman)
|
||||
* [Extras](#extras)
|
||||
* [How to use](#how-to-use)
|
||||
* [Basic control](#basic-control)
|
||||
* [Data](#data)
|
||||
* [Customization](#customization)
|
||||
* [How to scrobble](#how-to-scrobble)
|
||||
* [Native API](#native-api)
|
||||
* [Standard-compliant API](#standard-compliant-api)
|
||||
* [Manual](#manual)
|
||||
* [How to extend](#how-to-extend)
|
||||
|
||||
## Why not Last.fm / Libre.fm / GNU FM?
|
||||
## Features
|
||||
|
||||
Maloja is **self-hosted**. You will always be able to access your data in an easily-parseable format. Your library is not synced with any public or official music database, so you can **follow your own tagging schema** or even **group associated artists together** in your charts.
|
||||
|
||||
Maloja also gets **rid of all the extra stuff**: social networking, radios, recommendations, etc. It only keeps track of your listening history and lets you analyze it.
|
||||
|
||||
Maloja's database has one big advantage: It supports **multiple artists per track**. This means artists who are often just "featuring" in the track title get a place in your charts, and **collaborations between several artists finally get credited to all participants**. This allows you to get an actual idea of your artist preferences over time.
|
||||
|
||||
Also neat: You can use your **custom artist or track images**.
|
||||
* **Self-hosted**: You will always be able to access your data in an easily-parseable format. Your library is not synced with any public or official music database, so you can follow your own tagging schema.
|
||||
* **Associated Artists**: Compare different artists' popularity in your listening habits including subunits, collaboration projects or solo performances by their members. Change these associations at any time without losing any information.
|
||||
* **Multi-Artist Tracks**: Some artists often collaborate with others or are listed under "featuring" in the track title. Instead of tracking each combination of artists, each individual artist competes in your charts.
|
||||
* **Custom Images**: Don't rely on the community to select the best pictures for your favorite artists. Upload your own so that your start page looks like you want it to look.
|
||||
* **Proxy Scrobble**: No need to fully commit or set up every client twice - you can configure your Maloja server to forward your scrobbles to other services.
|
||||
* **Standard-compliant API**: Use existing, mature apps or extensions to scrobble to your Maloja server.
|
||||
* **Manual Scrobbling**: Listening to vinyl or elevator background music? Simply submit a scrobble with the web interface.
|
||||
* **Keep it Simple**: Unlike Last.fm and similar alternatives, Maloja doesn't have social networking, radios, recommendations or any other gimmicks. It's a tool to keep track of your listening habits over time - and nothing more.
|
||||
|
||||
|
||||
## How to install
|
||||
|
||||
### Environment
|
||||
### Requirements
|
||||
|
||||
I can support you with issues best if you use **Alpine Linux**. In my experience, **2-4 GB RAM** should do nicely.
|
||||
Maloja should run on any x86 or ARM machine that runs Python.
|
||||
|
||||
### New Installation
|
||||
I can support you with issues best if you use **Alpine Linux**.
|
||||
|
||||
1) Make sure you have Python 3.5 or higher installed. You also need some basic packages that should be present on most systems, but I've provided simple shell scripts for Alpine and Ubuntu to get everything you need.
|
||||
Your CPU should have a single core passmark score of at the very least 1500. 500 MB RAM should give you a decent experience, but performance will benefit greatly from up to 2 GB.
|
||||
|
||||
2) If you'd like to display images, you will need API keys for [Last.fm](https://www.last.fm/api/account/create) and [Fanart.tv](https://fanart.tv/get-an-api-key/) (you need a project key, not a personal one). These are free of charge!
|
||||
### PyPI
|
||||
|
||||
3) Download Maloja with the command `pip install malojaserver`. Make sure to use the correct python version (Use `pip3` if necessary).
|
||||
You can install Maloja with
|
||||
|
||||
4) (Recommended) Put your server behind a reverse proxy for SSL encryption. Make sure that you're proxying to the IPv6 address unless you changed your settings to use IPv4. If you're running Maloja in a container, make sure to expose port 32400 (or whichever port you have chosen in your settings).
|
||||
```console
|
||||
pip install malojaserver
|
||||
```
|
||||
|
||||
5) (Recommended) Until I have a proper service implemented, I would recommend setting two cronjobs for maloja:
|
||||
To make sure all dependencies are installed, you can also use one of the included scripts in the `install` folder.
|
||||
|
||||
### From Source
|
||||
|
||||
Clone this repository and enter the directory with
|
||||
|
||||
```console
|
||||
git clone https://github.com/krateng/maloja
|
||||
cd maloja
|
||||
```
|
||||
|
||||
Then install all the requirements and build the package, e.g.:
|
||||
|
||||
```console
|
||||
sh ./install/install_dependencies_alpine.sh
|
||||
pip install -r requirements.txt
|
||||
pip install .
|
||||
```
|
||||
|
||||
### Docker / Podman
|
||||
|
||||
Pull the [latest image](https://hub.docker.com/r/krateng/maloja) or check out the repository and use the included Containerfile.
|
||||
|
||||
Of note are these settings which should be passed as environmental variables to the container:
|
||||
|
||||
* `MALOJA_DATA_DIRECTORY` -- Set the directory in the container where configuration folders/files should be located
|
||||
* Mount a [volume](https://docs.docker.com/engine/reference/builder/#volume) to the specified directory to access these files outside the container (and to make them persistent)
|
||||
* `MALOJA_FORCE_PASSWORD` -- Set an admin password for maloja
|
||||
|
||||
You must publish a port on your host machine to bind to the container's web port (default 42010). The container uses IPv4 per default.
|
||||
|
||||
An example of a minimum run configuration to access maloja via `localhost:42010`:
|
||||
|
||||
```console
|
||||
docker run -p 42010:42010 -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja
|
||||
```
|
||||
|
||||
#### Linux Host
|
||||
|
||||
**NOTE:** If you are using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_) this DOES NOT apply to you.
|
||||
|
||||
If you are running Docker on a **Linux Host** you should specify `user:group` ids of the user who owns the folder on the host machine bound to `MALOJA_DATA_DIRECTORY` in order to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
|
||||
|
||||
To get the UID and GID for the current user run these commands from a terminal:
|
||||
|
||||
* `id -u` -- prints UID (EX `1000`)
|
||||
* `id -g` -- prints GID (EX `1001`)
|
||||
|
||||
The modified run command with these variables would look like:
|
||||
|
||||
```console
|
||||
docker run -e PUID=1000 -e PGID=1001 -p 42010:42010 -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja
|
||||
```
|
||||
|
||||
### Extras
|
||||
|
||||
* If you'd like to display images, you will need API keys for [Last.fm](https://www.last.fm/api/account/create) and [Spotify](https://developer.spotify.com/dashboard/applications). These are free of charge!
|
||||
|
||||
* Put your server behind a reverse proxy for SSL encryption. Make sure that you're proxying to the IPv6 or IPv4 address according to your settings.
|
||||
|
||||
* You can set up a cronjob to start your server on system boot, and potentially restart it on a regular basis:
|
||||
|
||||
```
|
||||
@reboot sleep 15 && maloja start
|
||||
42 0 * * 2 maloja restart
|
||||
42 0 7 * * maloja restart
|
||||
```
|
||||
|
||||
|
||||
### Update
|
||||
|
||||
* If you use a version before 2.0 (1.x), install the package as described above, then manually copy all your user data to your `~/.local/share/maloja` folder.
|
||||
* Otherwise, simply run the command `maloja update` or use `pip`s update mechanic.
|
||||
|
||||
|
||||
### Docker
|
||||
|
||||
There is a Dockerfile in the repo that should work by itself. You can also use the unofficial [Dockerhub repository](https://hub.docker.com/r/foxxmd/maloja) kindly provided by FoxxMD.
|
||||
|
||||
|
||||
## How to use
|
||||
|
||||
### Basic control
|
||||
|
||||
Start and stop the server with
|
||||
Start and stop the server in the background with
|
||||
|
||||
```console
|
||||
maloja start
|
||||
maloja stop
|
||||
maloja restart
|
||||
```
|
||||
|
||||
If something is not working, you can try
|
||||
If you need to run the server in the foreground, use
|
||||
|
||||
maloja debug
|
||||
|
||||
to run the server in the foreground.
|
||||
```console
|
||||
maloja run
|
||||
```
|
||||
|
||||
|
||||
### Data
|
||||
|
||||
* If you would like to import all your previous last.fm scrobbles, use [benfoxall's website](https://benjaminbenben.com/lastfm-to-csv/) ([GitHub page](https://github.com/benfoxall/lastfm-to-csv)). Use the command `maloja import *filename*` to import the downloaded file into Maloja.
|
||||
If you would like to import your previous scrobbles, use the command `maloja import *filename*`. This works on:
|
||||
|
||||
* To backup your data, run `maloja backup` or, to only backup essential data (no artwork etc), `maloja backup -l minimal`.
|
||||
* a Last.fm export generated by [benfoxall's website](https://benjaminbenben.com/lastfm-to-csv/) ([GitHub page](https://github.com/benfoxall/lastfm-to-csv))
|
||||
* an official [Spotify data export file](https://www.spotify.com/us/account/privacy/)
|
||||
* an official [ListenBrainz export file](https://listenbrainz.org/profile/export/)
|
||||
* the export of another Maloja instance
|
||||
|
||||
* To fix your database (e.g. after you've added new rules), use `maloja fix`.
|
||||
⚠️ Never import your data while maloja is running. When you need to do import inside docker container start it in shell mode instead and perform import before starting the container as mentioned above.
|
||||
|
||||
```console
|
||||
docker run -it --entrypoint sh -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja
|
||||
cd /mljdata
|
||||
maloja import my_last_fm_export.csv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
To backup your data, run `maloja backup`, optional with `--include_images`.
|
||||
|
||||
### Customization
|
||||
|
||||
* Have a look at the `default.ini` file in the `~/.local/share/maloja/settings` folder to see what settings are available. Specify your own choices in `settings.ini`. You can also set each of these settings as an environment variable with the prefix `MALOJA_` (e.g. `MALOJA_SKIP_SETUP`).
|
||||
* Have a look at the [available settings](settings.md) and specifiy your choices in `/etc/maloja/settings.ini`. You can also set each of these settings as an environment variable with the prefix `MALOJA_` (e.g. `MALOJA_SKIP_SETUP`).
|
||||
|
||||
* If you have activated admin mode in your web interface, you can upload custom images for artists or tracks by simply dragging them onto the existing image on the artist or track page. You can also manage custom images directly in the file system - consult `images.info` in the `~/.local/share/maloja/images` folder.
|
||||
* If you have activated admin mode in your web interface, you can upload custom images for artists or tracks by simply dragging them onto the existing image on the artist or track page. You can also manage custom images directly in the file system - consult `images.info` in the `/var/lib/maloja/images` folder.
|
||||
|
||||
* To specify custom rules, consult the `rules.info` file in `~/.local/share/maloja/rules`. You can also apply some predefined rules on the `/setup` page of your server.
|
||||
* To specify custom rules, consult the `rules.info` file in `/etc/maloja/rules`. You can also apply some predefined rules on the `/admin_setup` page of your server.
|
||||
|
||||
|
||||
## How to scrobble
|
||||
|
||||
You can set up any amount of API keys in the file `authenticated_machines.tsv` in the `~/.local/share/maloja/clients` folder.
|
||||
You can set up any amount of API keys in the file `apikeys.yml` in your configuration folder (or via the web interface). It is recommended to define a different API key for every scrobbler you use.
|
||||
|
||||
### Native API
|
||||
Some scrobbler clients support Maloja's native API. You can also use any scrobbler that allows you to set a custom Listenbrainz or GNUFM server. See [API.md](API.md) for details.
|
||||
|
||||
If you use Plex Web, Spotify, Bandcamp, Soundcloud or Youtube Music on Chromium, you can use the included extension (also available on the [Chrome Web Store](https://chrome.google.com/webstore/detail/maloja-scrobbler/cfnbifdmgbnaalphodcbandoopgbfeeh)). Make sure to enter the random key Maloja generates on first startup in the extension settings.
|
||||
If you're the maintainer of a music player or server and would like to implement native Maloja scrobbling, feel free to reach out!
|
||||
|
||||
If you want to implement your own method of scrobbling, it's very simple: You only need one POST request to `/api/newscrobble` with the keys `artist`, `title` and `key` - either as form-data or json.
|
||||
|
||||
### Standard-compliant API
|
||||
|
||||
You can use any third-party scrobbler that supports the audioscrobbler (GNUFM) or the ListenBrainz protocol. This is still very experimental, but give it a try with these settings:
|
||||
|
||||
GNU FM |
|
||||
------ | ---------
|
||||
Gnukebox URL | Your Maloja URL followed by `/api/s/audioscrobbler`
|
||||
Username | Any name, doesn't matter
|
||||
Password | Any of your API keys
|
||||
|
||||
ListenBrainz |
|
||||
------ | ---------
|
||||
API URL | Your Maloja URL followed by `/api/s/listenbrainz`
|
||||
Username | Any name, doesn't matter
|
||||
Auth Token | Any of your API keys
|
||||
|
||||
These are tested with the Pano Scrobbler and the Simple Last.fm Scrobbler for Android. I'm thankful for any feedback whether other scrobblers work!
|
||||
|
||||
It is recommended to define a different API key for every scrobbler you use in `clients/authenticated_machines.tsv` in your Maloja folder.
|
||||
|
||||
### Manual
|
||||
|
||||
If you can't automatically scrobble your music, you can always do it manually on the `/manual` page of your Maloja server.
|
||||
If you can't automatically scrobble your music, you can always do it manually on the `/admin_manual` page of your Maloja server.
|
||||
|
||||
|
||||
## How to extend
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
screenshot.png
|
||||
tile.png
|
||||
!*.sh
|
Binary file not shown.
|
@ -1,5 +1,4 @@
|
|||
|
||||
|
||||
chrome.tabs.onUpdated.addListener(onTabUpdated);
|
||||
chrome.tabs.onRemoved.addListener(onTabRemoved);
|
||||
//chrome.tabs.onActivated.addListener(onTabChanged);
|
||||
|
@ -7,8 +6,13 @@ chrome.runtime.onMessage.addListener(onInternalMessage);
|
|||
|
||||
tabManagers = {}
|
||||
|
||||
|
||||
const ALWAYS_SCROBBLE_SECONDS = 60*3;
|
||||
// Longer songs are always scrobbled when playing at least 2 minutes
|
||||
|
||||
pages = {
|
||||
"Plex Web":{
|
||||
"plex":{
|
||||
"name":"Plex",
|
||||
"patterns":[
|
||||
"https://app.plex.tv",
|
||||
"http://app.plex.tv",
|
||||
|
@ -17,29 +21,41 @@ pages = {
|
|||
],
|
||||
"script":"plex.js"
|
||||
},
|
||||
"YouTube Music":{
|
||||
"ytmusic":{
|
||||
"name":"YouTube Music",
|
||||
"patterns":[
|
||||
"https://music.youtube.com"
|
||||
],
|
||||
"script":"ytmusic.js"
|
||||
},
|
||||
"Spotify Web":{
|
||||
"spotify":{
|
||||
"name":"Spotify",
|
||||
"patterns":[
|
||||
"https://open.spotify.com"
|
||||
],
|
||||
"script":"spotify.js"
|
||||
},
|
||||
"Bandcamp":{
|
||||
"bandcamp":{
|
||||
"name":"Bandcamp",
|
||||
"patterns":[
|
||||
"bandcamp.com"
|
||||
],
|
||||
"script":"bandcamp.js"
|
||||
},
|
||||
"Soundcloud":{
|
||||
"soundcloud":{
|
||||
"name":"Soundcloud",
|
||||
"patterns":[
|
||||
"https://soundcloud.com"
|
||||
],
|
||||
"script":"soundcloud.js"
|
||||
},
|
||||
"navidrome":{
|
||||
"name":"Navidrome",
|
||||
"patterns":[
|
||||
"https://navidrome.",
|
||||
"http://navidrome."
|
||||
],
|
||||
"script":"navidrome.js"
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -67,6 +83,13 @@ function onTabUpdated(tabId, changeInfo, tab) {
|
|||
//console.log("Still on same page!")
|
||||
tabManagers[tabId].update();
|
||||
|
||||
// check if the setting for this page is still active
|
||||
chrome.storage.local.get(["service_active_" + page],function(result){
|
||||
if (!result["service_active_" + page]) {
|
||||
delete tabManagers[tabId];
|
||||
}
|
||||
});
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -80,13 +103,21 @@ function onTabUpdated(tabId, changeInfo, tab) {
|
|||
patterns = pages[key]["patterns"];
|
||||
for (var i=0;i<patterns.length;i++) {
|
||||
if (tab.url.includes(patterns[i])) {
|
||||
console.log("New page on tab " + tabId + " will be handled by new " + key + " manager!");
|
||||
tabManagers[tabId] = new Controller(tabId,key);
|
||||
updateTabNum();
|
||||
return
|
||||
//chrome.tabs.executeScript(tab.id,{"file":"sitescripts/" + pages[key]["script"]})
|
||||
|
||||
// check if we even like that page
|
||||
chrome.storage.local.get(["service_active_" + key],function(result){
|
||||
if (result["service_active_" + key]) {
|
||||
console.log("New page on tab " + tabId + " will be handled by new " + key + " manager!");
|
||||
tabManagers[tabId] = new Controller(tabId,key);
|
||||
updateTabNum();
|
||||
//chrome.tabs.executeScript(tab.id,{"file":"sitescripts/" + pages[key]["script"]})
|
||||
}
|
||||
else {
|
||||
console.log("New page on tab " + tabId + " is " + key + ", not enabled!");
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -117,10 +148,10 @@ function onInternalMessage(request,sender) {
|
|||
for (tabId in tabManagers) {
|
||||
manager = tabManagers[tabId]
|
||||
if (manager.currentlyPlaying) {
|
||||
answer.push([manager.page,manager.currentArtist,manager.currentTitle]);
|
||||
answer.push([pages[manager.page]['name'],manager.currentArtist,manager.currentTitle]);
|
||||
}
|
||||
else {
|
||||
answer.push([manager.page,null]);
|
||||
answer.push([pages[manager.page]['name'],null]);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -207,6 +238,14 @@ class Controller {
|
|||
}
|
||||
}
|
||||
|
||||
backlog_scrobble() {
|
||||
while (this.alreadyPlayed > this.currentLength) {
|
||||
this.alreadyPlayed = this.alreadyPlayed - this.currentLength
|
||||
var secondsago = this.alreadyPlayed
|
||||
scrobble(this.currentArtist,this.currentTitle,this.currentLength,secondsago)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -216,12 +255,8 @@ class Controller {
|
|||
if (artist == this.currentArtist && title == this.currentTitle && !this.currentlyPlaying) {
|
||||
console.log("Resuming playback of " + this.currentTitle)
|
||||
|
||||
// Already played full song
|
||||
while (this.alreadyPlayed > this.currentLength) {
|
||||
this.alreadyPlayed = this.alreadyPlayed - this.currentLength
|
||||
var secondsago = this.alreadyPlayed
|
||||
scrobble(this.currentArtist,this.currentTitle,this.currentLength,secondsago)
|
||||
}
|
||||
// Already played full song?
|
||||
this.backlog_scrobble()
|
||||
|
||||
this.setUpdate()
|
||||
this.currentlyPlaying = true
|
||||
|
@ -263,12 +298,8 @@ class Controller {
|
|||
}
|
||||
|
||||
|
||||
// Already played full song
|
||||
while (this.alreadyPlayed > this.currentLength) {
|
||||
this.alreadyPlayed = this.alreadyPlayed - this.currentLength
|
||||
var secondsago = this.alreadyPlayed
|
||||
scrobble(this.currentArtist,this.currentTitle,this.currentLength,secondsago)
|
||||
}
|
||||
// Already played full song?
|
||||
this.backlog_scrobble()
|
||||
|
||||
this.currentlyPlaying = false
|
||||
|
||||
|
@ -276,7 +307,7 @@ class Controller {
|
|||
|
||||
//ONLY CASE 2: Playback ended
|
||||
if (artist != this.currentArtist || title != this.currentTitle) {
|
||||
if (this.alreadyPlayed > this.currentLength / 2) {
|
||||
if ((this.alreadyPlayed > this.currentLength / 2) || (this.alreadyPlayed > ALWAYS_SCROBBLE_SECONDS)) {
|
||||
scrobble(this.currentArtist,this.currentTitle,this.alreadyPlayed)
|
||||
this.alreadyPlayed = 0
|
||||
}
|
||||
|
@ -303,21 +334,25 @@ class Controller {
|
|||
|
||||
function scrobble(artist,title,seconds,secondsago=0) {
|
||||
console.log("Scrobbling " + artist + " - " + title + "; " + seconds + " seconds playtime, " + secondsago + " seconds ago")
|
||||
var artiststring = encodeURIComponent(artist)
|
||||
var titlestring = encodeURIComponent(title)
|
||||
var d = new Date()
|
||||
var time = Math.floor(d.getTime()/1000) - secondsago
|
||||
//console.log("Time: " + time)
|
||||
var requestbody = "artist=" + artiststring + "&title=" + titlestring + "&duration=" + seconds + "&time=" + time
|
||||
chrome.storage.local.get("apikey",function(result) {
|
||||
APIKEY = result["apikey"]
|
||||
chrome.storage.local.get("serverurl",function(result) {
|
||||
URL = result["serverurl"]
|
||||
var xhttp = new XMLHttpRequest();
|
||||
xhttp.open("POST",URL + "/api/newscrobble",true);
|
||||
xhttp.send(requestbody + "&key=" + APIKEY)
|
||||
//console.log("Sent: " + requestbody)
|
||||
});
|
||||
var payload = {
|
||||
"artist":artist,
|
||||
"title":title,
|
||||
"duration":seconds,
|
||||
"time":time
|
||||
}
|
||||
|
||||
chrome.storage.local.get(["serverurl","apikey"],function(result) {
|
||||
payload["key"] = result["apikey"];
|
||||
var xhttp = new XMLHttpRequest();
|
||||
xhttp.open("POST",result["serverurl"] + "/apis/mlj_1/newscrobble",true);
|
||||
xhttp.setRequestHeader("Content-Type", "application/json");
|
||||
//xhttp.send(requestbody + "&key=" + APIKEY)
|
||||
var body = JSON.stringify(payload);
|
||||
xhttp.send(body)
|
||||
//console.log("Sent: " + body)
|
||||
});
|
||||
|
||||
|
Binary file not shown.
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
|
@ -1,15 +1,13 @@
|
|||
{
|
||||
"name": "Maloja Scrobbler",
|
||||
"version": "1.5",
|
||||
"version": "1.13",
|
||||
"description": "Scrobbles tracks from various sites to your Maloja server",
|
||||
"manifest_version": 2,
|
||||
"permissions": ["activeTab",
|
||||
"declarativeContent",
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"storage",
|
||||
"http://*/",
|
||||
"https://*/",
|
||||
"<all_urls>"
|
||||
"https://*/"
|
||||
],
|
||||
"background":
|
||||
{
|
||||
|
@ -28,7 +26,7 @@
|
|||
"48":"icon48.png"
|
||||
},
|
||||
"default_popup": "settings.html",
|
||||
"default_title": "Settings"
|
||||
"default_title": "Maloja Scrobbler"
|
||||
},
|
||||
"icons":
|
||||
{
|
|
@ -14,7 +14,7 @@
|
|||
color:beige;
|
||||
font-family:'Ubuntu';
|
||||
}
|
||||
input {
|
||||
input[type=text] {
|
||||
width:270px;
|
||||
font-family:'Ubuntu';
|
||||
outline:none;
|
||||
|
@ -29,14 +29,18 @@
|
|||
<body>
|
||||
<div id="wat">
|
||||
<span id="checkmark_url"></span> <span>Server:</span><br />
|
||||
<input type="text" id="serverurl" value="http://localhost:42010" />
|
||||
<input type="text" id="serverurl" />
|
||||
<br /><br />
|
||||
<span id="checkmark_key"></span> <span>API key:</span><br />
|
||||
<input type="text" id="apikey" />
|
||||
<br/><br/>
|
||||
<hr/>
|
||||
<span>Tabs:</span>
|
||||
<list id="playinglist">
|
||||
</list>
|
||||
<hr/>
|
||||
<span>Services:</span>
|
||||
<list id="sitelist">
|
||||
</list>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,7 +1,44 @@
|
|||
// duplicate this info for now, don't know if there is a better way than sending messages
|
||||
var pages = {
|
||||
"plex":"Plex",
|
||||
"ytmusic":"YouTube Music",
|
||||
"spotify":"Spotify",
|
||||
"bandcamp":"Bandcamp",
|
||||
"soundcloud":"Soundcloud",
|
||||
"navidrome":"Navidrome"
|
||||
}
|
||||
|
||||
var config_defaults = {
|
||||
serverurl:"http://localhost:42010",
|
||||
apikey:"BlackPinkInYourArea"
|
||||
}
|
||||
|
||||
for (var key in pages) {
|
||||
config_defaults["service_active_" + key] = true;
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener("DOMContentLoaded",function() {
|
||||
document.getElementById("serverurl").addEventListener("input",updateServer);
|
||||
document.getElementById("apikey").addEventListener("input",updateAPIKey);
|
||||
|
||||
var sitelist = document.getElementById("sitelist");
|
||||
|
||||
|
||||
for (var identifier in pages) {
|
||||
sitelist.append(document.createElement('br'));
|
||||
var checkbox = document.createElement('input');
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.id = "service_active_" + identifier;
|
||||
var label = document.createElement('label');
|
||||
label.for = checkbox.id;
|
||||
label.textContent = pages[identifier];
|
||||
sitelist.appendChild(checkbox);
|
||||
sitelist.appendChild(label);
|
||||
|
||||
checkbox.addEventListener("change",toggleSite);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
document.getElementById("serverurl").addEventListener("change",checkServer);
|
||||
document.getElementById("apikey").addEventListener("change",checkServer);
|
||||
|
@ -9,18 +46,29 @@ document.addEventListener("DOMContentLoaded",function() {
|
|||
document.getElementById("serverurl").addEventListener("focusout",checkServer);
|
||||
document.getElementById("apikey").addEventListener("focusout",checkServer);
|
||||
|
||||
document.getElementById("serverurl").addEventListener("input",saveServer);
|
||||
document.getElementById("apikey").addEventListener("input",saveServer);
|
||||
|
||||
|
||||
chrome.runtime.onMessage.addListener(onInternalMessage);
|
||||
|
||||
chrome.storage.local.get(config_defaults,function(result){
|
||||
console.log(result);
|
||||
for (var key in result) {
|
||||
|
||||
chrome.storage.local.get({"serverurl":"http://localhost:42010"},function(result) {
|
||||
document.getElementById("serverurl").value = result["serverurl"]
|
||||
checkServerMaybe()
|
||||
});
|
||||
chrome.storage.local.get({"apikey":"BlackPinkInYourArea"},function(result) {
|
||||
document.getElementById("apikey").value = result["apikey"]
|
||||
checkServerMaybe()
|
||||
});
|
||||
// booleans
|
||||
if (result[key] == true || result[key] == false) {
|
||||
document.getElementById(key).checked = result[key];
|
||||
}
|
||||
|
||||
// text
|
||||
else{
|
||||
document.getElementById(key).value = result[key];
|
||||
}
|
||||
|
||||
}
|
||||
checkServer();
|
||||
})
|
||||
|
||||
chrome.runtime.sendMessage({"type":"query"})
|
||||
|
||||
|
@ -28,18 +76,12 @@ document.addEventListener("DOMContentLoaded",function() {
|
|||
|
||||
});
|
||||
|
||||
|
||||
//this makes sure only the second call actually makes a request (the first request is pointless
|
||||
//when the other element isn't filled yet and might actually overwrite the correct result because
|
||||
//of a race condition)
|
||||
var done = 0
|
||||
function checkServerMaybe() {
|
||||
done++;
|
||||
if (done == 2) {
|
||||
checkServer()
|
||||
}
|
||||
function toggleSite(evt) {
|
||||
var element = evt.target;
|
||||
chrome.storage.local.set({ [element.id]: element.checked });
|
||||
}
|
||||
|
||||
|
||||
function onInternalMessage(request,sender) {
|
||||
if (request.type == "response") {
|
||||
players = request.content
|
||||
|
@ -58,17 +100,11 @@ function onInternalMessage(request,sender) {
|
|||
|
||||
|
||||
|
||||
function updateServer() {
|
||||
|
||||
text = document.getElementById("serverurl").value
|
||||
|
||||
|
||||
chrome.storage.local.set({"serverurl":text})
|
||||
}
|
||||
|
||||
function updateAPIKey() {
|
||||
text = document.getElementById("apikey").value
|
||||
chrome.storage.local.set({"apikey":text})
|
||||
function saveServer() {
|
||||
for (var key of ["serverurl","apikey"]) {
|
||||
var value = document.getElementById(key).value;
|
||||
chrome.storage.local.set({ [key]: value });
|
||||
}
|
||||
}
|
||||
|
||||
function checkServer() {
|
||||
|
@ -91,7 +127,7 @@ function checkServer() {
|
|||
|
||||
function createCheckmarks() {
|
||||
if (this.readyState == 4) {
|
||||
if ((this.status == 204) || (this.status == 205)) {
|
||||
if ((this.status >= 200) && (this.status < 300)) {
|
||||
//document.getElementById("checkmark_url").innerHTML = "✔️"
|
||||
//document.getElementById("checkmark_key").innerHTML = "✔️"
|
||||
document.getElementById("serverurl").style.backgroundColor = "lawngreen"
|
|
@ -0,0 +1,14 @@
|
|||
maloja_scrobbler_selector_playbar = "//div[contains(@class,'music-player-panel')]"
|
||||
|
||||
|
||||
maloja_scrobbler_selector_metadata = ".//span[contains(@class,'audio-title')]"
|
||||
|
||||
maloja_scrobbler_selector_title = ".//span[contains(@class,'songTitle')]/text()"
|
||||
maloja_scrobbler_selector_artist = ".//span[contains(@class,'songArtist')]/text()"
|
||||
maloja_scrobbler_selector_duration = ".//span[contains(@class,'duration')]/text()"
|
||||
|
||||
|
||||
maloja_scrobbler_selector_control = ".//span[contains(@class,'group play-btn')]/@title"
|
||||
|
||||
maloja_scrobbler_label_playing = "Click to pause"
|
||||
maloja_scrobbler_label_paused = "Click to play"
|
|
@ -3,9 +3,9 @@ maloja_scrobbler_selector_playbar = "//div[contains(@class,'PlayerControls')]"
|
|||
|
||||
maloja_scrobbler_selector_metadata = ".//div[contains(@class,'PlayerControlsMetadata-container')]"
|
||||
|
||||
maloja_scrobbler_selector_title = ".//a[@data-qa-id='metadataTitleLink']/@title"
|
||||
maloja_scrobbler_selector_title = ".//a[@data-testid='metadataTitleLink']/@title"
|
||||
maloja_scrobbler_selector_artist = ".//span[contains(@class,'MetadataPosterTitle-title')]/a[1]/@title"
|
||||
maloja_scrobbler_selector_duration = ".//button[@data-qa-id='mediaDuration']/text()[3]"
|
||||
maloja_scrobbler_selector_duration = ".//button[@data-testid='mediaDuration']/text()[3]"
|
||||
|
||||
|
||||
maloja_scrobbler_selector_control = ".//div[contains(@class,'PlayerControls-buttonGroupCenter')]/button[2]/@title"
|
|
@ -0,0 +1,12 @@
|
|||
maloja_scrobbler_selector_playbar = "//footer[@data-testid='now-playing-bar']"
|
||||
|
||||
|
||||
maloja_scrobbler_selector_metadata = ".//div[@data-testid='now-playing-widget']"
|
||||
|
||||
maloja_scrobbler_selector_title = ".//a[@data-testid='context-item-link']/text()"
|
||||
maloja_scrobbler_selector_artists = ".//a[contains(@href,'/artist/')]"
|
||||
maloja_scrobbler_selector_artist = "./text()"
|
||||
maloja_scrobbler_selector_duration = ".//div[@data-testid='playback-duration']/text()"
|
||||
|
||||
|
||||
maloja_scrobbler_selector_control = ".//button[@data-testid='control-button-playpause']/@aria-label"
|
|
@ -32,7 +32,7 @@ Node.prototype.xpath = getxpath;
|
|||
|
||||
bar = document.xpath(maloja_scrobbler_selector_playbar, XPathResult.FIRST_ORDERED_NODE_TYPE);
|
||||
if (bar == null) {
|
||||
console.log("Nothing playing right now!");
|
||||
console.log("[Maloja Scrobbler] Nothing playing right now!");
|
||||
chrome.runtime.sendMessage({type:"stopPlayback",time:Math.floor(Date.now()),artist:"",title:""});
|
||||
}
|
||||
else {
|
||||
|
@ -78,12 +78,12 @@ else {
|
|||
label_paused = "Play"
|
||||
}
|
||||
if (control == label_paused) {
|
||||
console.log("Not playing right now");
|
||||
console.log("[Maloja Scrobbler] Not playing right now");
|
||||
chrome.runtime.sendMessage({type:"stopPlayback",time:Math.floor(Date.now()),artist:artist,title:title});
|
||||
//stopPlayback()
|
||||
}
|
||||
else if (control == label_playing) {
|
||||
console.log("Playing " + artist + " - " + title + " (" + durationSeconds + " sec)");
|
||||
console.log("[Maloja Scrobbler] Playing " + artist + " - " + title + " (" + durationSeconds + " sec)");
|
||||
chrome.runtime.sendMessage({type:"startPlayback",time:Math.floor(Date.now()),artist:artist,title:title,duration:durationSeconds});
|
||||
//startPlayback(artist,title,durationSeconds)
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
# maloja-lib
|
||||
|
||||
Library for Python music players to allow users to scrobble to [Maloja](https://github.com/krateng/maloja) servers.
|
||||
|
||||
```
|
||||
from malojalib import MalojaInstance
|
||||
|
||||
instance = MalojaInstance(user_supplied_url,user_supplied_key)
|
||||
|
||||
instance.scrobble(artists=['K/DA','Howard Shore','Blackbeard's Tea Party],title='Grüezi Wohl Frau Stirnimaa')
|
||||
|
||||
```
|
|
@ -0,0 +1,27 @@
|
|||
import requests
|
||||
|
||||
class MalojaInstance:
|
||||
def __init__(self,base_url,key):
|
||||
self.base_url = base_url
|
||||
self.key = key
|
||||
|
||||
def test(self):
|
||||
url = self.base_url + '/apis/mlj_1/test'
|
||||
response = requests.get(url,{'key':self.key})
|
||||
|
||||
return (response.status_code == 200)
|
||||
|
||||
def scrobble(self,artists,title,timestamp=None,album=None,duration=None):
|
||||
payload = {
|
||||
'key':self.key,
|
||||
'artists':artists,
|
||||
'title':title,
|
||||
'time':timestamp,
|
||||
'album':album,
|
||||
'duration':duration
|
||||
}
|
||||
|
||||
url = self.base_url + '/apis/mlj_1/newscrobble'
|
||||
response = requests.post(url,payload)
|
||||
|
||||
return response.json()
|
|
@ -0,0 +1,27 @@
|
|||
[project]
|
||||
name = "maloja-lib"
|
||||
version = "1.0.0"
|
||||
description = "Utilities to interact with Maloja servers"
|
||||
readme = "./README.md"
|
||||
requires-python = ">=3.6"
|
||||
license = { file="../../LICENSE" }
|
||||
authors = [ { name="Johannes Krattenmacher", email="maloja@dev.krateng.ch" } ]
|
||||
|
||||
urls.repository = "https://github.com/krateng/maloja"
|
||||
urls.documentation = "https://github.com/krateng/maloja"
|
||||
|
||||
keywords = ["scrobbling", "music", "library", "api"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
"Operating System :: OS Independent"
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"requests"
|
||||
]
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["flit_core >=3.2,<4"]
|
||||
build-backend = "flit_core.buildapi"
|
|
@ -0,0 +1,10 @@
|
|||
#!/usr/bin/with-contenv bash
|
||||
|
||||
if [ "$(s6-setuidgid abc id -u)" = "0" ]; then
|
||||
echo "-------------------------------------"
|
||||
echo "WARN: Running as root! If you meant to do this than this message can be ignored."
|
||||
echo "If you are running this container on a *linux* host and are not using podman rootless you SHOULD"
|
||||
echo "change the ENVs PUID and PGID for this container to ensure correct permissions on your config folder."
|
||||
echo -e "See: https://github.com/krateng/maloja#linux-host\n"
|
||||
echo -e "-------------------------------------\n"
|
||||
fi
|
|
@ -0,0 +1 @@
|
|||
oneshot
|
|
@ -0,0 +1 @@
|
|||
/etc/s6-overlay/s6-rc.d/init-permission-check/run
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/with-contenv bash
|
||||
|
||||
# used https://github.com/linuxserver/docker-wikijs/blob/master/root/etc/s6-overlay/s6-rc.d/svc-wikijs/run as a template
|
||||
|
||||
echo -e "\nMaloja is starting!"
|
||||
exec \
|
||||
s6-setuidgid abc python -m maloja run
|
|
@ -0,0 +1 @@
|
|||
longrun
|
|
@ -0,0 +1 @@
|
|||
git tag -l '*.0' -n1 --sort=v:refname
|
|
@ -0,0 +1,21 @@
|
|||
import toml
|
||||
import os
|
||||
|
||||
with open("pyproject.toml") as filed:
|
||||
data = toml.load(filed)
|
||||
|
||||
info = {
|
||||
'name':data['project']['name'],
|
||||
'license':"GPLv3",
|
||||
'version':data['project']['version'],
|
||||
'architecture':'all',
|
||||
'description':'"' + data['project']['description'] + '"',
|
||||
'url':'"' + data['project']['urls']['homepage'] + '"',
|
||||
'maintainer':f"\"{data['project']['authors'][0]['name']} <{data['project']['authors'][0]['email']}>\"",
|
||||
}
|
||||
|
||||
|
||||
for target in ["apk","deb"]:
|
||||
lcmd = f"fpm {' '.join(f'--{key} {info[key]}' for key in info)} -s python -t {target} . "
|
||||
print(lcmd)
|
||||
os.system(lcmd)
|
|
@ -0,0 +1,3 @@
|
|||
minor_release_name: "Yura"
|
||||
'1.0':
|
||||
commit: "1fac2ca965fdbe40c85a88559d5b736f4829e7b0"
|
|
@ -0,0 +1,3 @@
|
|||
minor_release_name: "Solar"
|
||||
'1.1':
|
||||
commit: "5603ca9eb137516e604e9e3e83e273a70ef32f65"
|
|
@ -0,0 +1,3 @@
|
|||
minor_release_name: "Jeonghwa"
|
||||
'1.2':
|
||||
commit: "d46d2be2bf27ef40ddd9f0c077f86dcf0214adbb"
|
|
@ -0,0 +1,3 @@
|
|||
minor_release_name: "IU"
|
||||
'1.3':
|
||||
commit: "0bf1790a7cc0174b84f8c25dade6b221b13d65e9"
|
|
@ -0,0 +1,3 @@
|
|||
minor_release_name: "Chungha"
|
||||
'1.4':
|
||||
commit: "981c0e4ae2ad1bff5a0778b6fa34916b0c4d4f4a"
|
|
@ -0,0 +1,3 @@
|
|||
minor_release_name: "Seulgi"
|
||||
'1.5':
|
||||
commit: "e282789153ec3df133474a56e8d922a73795b72a"
|
|
@ -0,0 +1,5 @@
|
|||
minor_release_name: "Irene"
|
||||
'2.0':
|
||||
commit: "55621ef4efdf61c3092d42565e897dfbaa0244c8"
|
||||
notes:
|
||||
- "[Architecture] Refactored into Python Package"
|
|
@ -0,0 +1,5 @@
|
|||
minor_release_name: "Jennie"
|
||||
2.1.0:
|
||||
commit: "b87379ed986640788201f1ff52826413067e5ffb"
|
||||
2.1.4:
|
||||
commit: "c95ce17451cb19b4775a819f82a532d3a3a6231b"
|
|
@ -0,0 +1,17 @@
|
|||
minor_release_name: "Yeri"
|
||||
2.10.0:
|
||||
commit: "ce9d882856be8f6caca14ab7e5b9f13d6c31940b"
|
||||
2.10.1:
|
||||
commit: "f555ee9d9fc485c6a241f4a8fa88bd68527ed2e2"
|
||||
2.10.2:
|
||||
commit: "9a6617b4b1117a6e53d818aadcda886c831e16db"
|
||||
2.10.3:
|
||||
commit: "5b7d1fd8e9f70a8bf9c5bfbe4aca5b796578e114"
|
||||
2.10.4:
|
||||
commit: "3cf0dd9767fe62702b2f5c4f0267a234338a972b"
|
||||
2.10.5:
|
||||
commit: "034bd064f11ef18ebbfcb25bd8acac8eacce1324"
|
||||
2.10.6:
|
||||
commit: "f62fd254dd44deca50a860f3a966390ae9c3662c"
|
||||
2.10.7:
|
||||
commit: "212fbf368e38281f45a0d8dd63dc051dbd8cd8cf"
|
|
@ -0,0 +1,3 @@
|
|||
minor_release_name: "Akali"
|
||||
2.11.0:
|
||||
commit: "218313f80c160f90b28d99236a062ef62db7260d"
|
|
@ -0,0 +1,41 @@
|
|||
minor_release_name: "Tzuyu"
|
||||
2.12.0:
|
||||
commit: "723efcb8ba12f7bda9acc21d81d9930265881c15"
|
||||
2.12.1:
|
||||
commit: "a42ed56d2de47f88f873737f0f1374e99be895bf"
|
||||
2.12.2:
|
||||
commit: "5006ad2bf1a6d5132ed07d28a7f6f0a9a454d5a7"
|
||||
2.12.3:
|
||||
commit: "2a5d9498d1dd7bb6ac62a27d518a87542ec3f344"
|
||||
2.12.4:
|
||||
commit: "06c32e205e95df8d3d1e27876887b7da7aa2bdf4"
|
||||
2.12.5:
|
||||
commit: "c2f8ecc2dfa1ac4febde228fce150e08fb47be38"
|
||||
2.12.6:
|
||||
commit: "a652a22a96ab693a4e7c3271520e6ad79fa025af"
|
||||
2.12.7:
|
||||
commit: "5455abd0d1c9ecc8e000d04257f721babacb18e9"
|
||||
2.12.8:
|
||||
commit: "8ebd27ab76ad2dcaf1dfef0cc171900fa20d5ee5"
|
||||
2.12.9:
|
||||
commit: "49598e914f4e2d7895959bea954238db8a6cee78"
|
||||
2.12.10:
|
||||
commit: "9037403777faa089092cf103181027febf6f0340"
|
||||
2.12.12:
|
||||
commit: "eaaa0f3b53aa102ba1eb709c1803e94752017d86"
|
||||
2.12.13:
|
||||
commit: "c31770a34c96cecc87af78f861819cc49fe98dda"
|
||||
2.12.14:
|
||||
commit: "5157ce825eea9bf7b74123cb02dd28e25c6a0767"
|
||||
2.12.15:
|
||||
commit: "33af60ed2c8c980f17338827a9cb96e7f2fd2572"
|
||||
2.12.16:
|
||||
commit: "26dfdfb569d0beaf4ba8c6c67a9e2295d1362eed"
|
||||
2.12.17:
|
||||
commit: "21012234409c01fec3cb5c506f0b4ba74b735b0b"
|
||||
2.12.18:
|
||||
commit: "59eaa2264aefa6c9ed5f38e8490f77150bcae27b"
|
||||
2.12.19:
|
||||
commit: "8958eb1b547f07d5d063c46cbe59ec57e000ecae"
|
||||
2.12.20:
|
||||
commit: "7774d9a9361db986092e143e1bc397ce7a7524dd"
|
|
@ -0,0 +1,11 @@
|
|||
minor_release_name: "Aqua"
|
||||
2.13.0:
|
||||
commit: "8555b28fbc9a220577260014b7f71f433263cb9f"
|
||||
2.13.1:
|
||||
commit: "cefed03bc95dd5641b918f79b6ed14b2bfc9898d"
|
||||
2.13.2:
|
||||
commit: "0f5ccd4645ead8d1ad48a532752d401424edb236"
|
||||
2.13.3:
|
||||
commit: "40648b66f36894a297633c650e570ac77555d143"
|
||||
2.13.4:
|
||||
commit: "0ccd39ffd99b19e4cd1b1a14f97bfb4385662eeb"
|
|
@ -0,0 +1,23 @@
|
|||
minor_release_name: "Mina"
|
||||
2.14.0:
|
||||
commit: "1b0e3ffdb2389ae6ca484c78840756d0b7e5c0be"
|
||||
2.14.1:
|
||||
commit: "fb2dff8addc7eaf740c5e30cbcd6791aab882c56"
|
||||
2.14.2:
|
||||
commit: "cd8e0ff90abf7d01761b0576c4168254b9b1f7c1"
|
||||
2.14.3:
|
||||
commit: "f806fb8ed24dd1474b80e7b1a9a7637cdbd35905"
|
||||
2.14.4:
|
||||
commit: "868b8396a0a4ff0f687e651772d746af6d9dfab1"
|
||||
2.14.5:
|
||||
commit: "21d1643988e40a02531bcc708f43925789d854d1"
|
||||
2.14.6:
|
||||
commit: "ccbb3d3a807fd77a1481f9d44f311c7f8df659c7"
|
||||
2.14.7:
|
||||
commit: "634df2ffafdfa00b6caf981108d333e30bf160f8"
|
||||
2.14.8:
|
||||
commit: "ec5723d2b3122faaa5b76c5a1b156c9a915af9d6"
|
||||
2.14.9:
|
||||
commit: "2c73c81434e1a591685a4b1d267a9eb6dbd57174"
|
||||
2.14.10:
|
||||
commit: "e152a2edde836f8fb30427d13eb1e9e0d591a00b"
|
|
@ -0,0 +1,11 @@
|
|||
minor_release_name: "Rosé"
|
||||
2.2.0:
|
||||
commit: "33cea26a791e224625aa9bc523e2cf90e39c8a50"
|
||||
2.2.1:
|
||||
commit: "fbce600c4edd2b530e6673b89513b1a26b068b64"
|
||||
2.2.2:
|
||||
commit: "c518627670f5614a2b9931471337a1a6b2ee344f"
|
||||
2.2.3:
|
||||
commit: "a2cc27ddd46c7cf9959f33478eac396e18f90055"
|
||||
2.2.4:
|
||||
commit: "c6deb1543779ce8b09af6bcbdc35e7668af86010"
|
|
@ -0,0 +1,19 @@
|
|||
minor_release_name: "Nancy"
|
||||
2.3.0:
|
||||
commit: "8793b149f501fe5f3e237d7ae0fcd23c8f4e5e9d"
|
||||
2.3.1:
|
||||
commit: "7c6e2ad60f15d8c4ac85a0808a0abd07549a4a2b"
|
||||
2.3.2:
|
||||
commit: "5a08fd78c69c4047b82ff9c394ea23d25356758e"
|
||||
2.3.3:
|
||||
commit: "9cf1fb3ed83817168dfe2ac30a42dcadb080c043"
|
||||
2.3.4:
|
||||
commit: "eb82282e58259b243958e7590506bd26f8e92db0"
|
||||
2.3.5:
|
||||
commit: "a4f13f6923b7783509462944f1abb235b4a068d0"
|
||||
2.3.6:
|
||||
commit: "b611387011e4cbd274e210d0c21c83d15302281c"
|
||||
2.3.7:
|
||||
commit: "b17060184b6897b18cf8af28a3817c9989aac96f"
|
||||
2.3.8:
|
||||
commit: "afe01c8341acd4cf9f4b84fbba85aab6777fd230"
|
|
@ -0,0 +1,29 @@
|
|||
minor_release_name: "Songhee"
|
||||
2.4.0:
|
||||
commit: "6aa65bf1ce273d9fd36d44f6e24439981b2228a3"
|
||||
2.4.1:
|
||||
commit: "b117e6f7ec80afc6210314ce97bac087d5ab7b54"
|
||||
2.4.2:
|
||||
commit: "d989134e65c20ab33b0ea8e4a132655074057757"
|
||||
2.4.3:
|
||||
commit: "9b787fa3b13d77a9cfbe21061f519defac7fafd0"
|
||||
2.4.4:
|
||||
commit: "948772b1c26070d7814871824b970fb60fc6976d"
|
||||
2.4.5:
|
||||
commit: "2da5ab83b3a410b02af48e70b298069218a7e2a3"
|
||||
2.4.6:
|
||||
commit: "65f9c88da4d56df37e4a3f974d7f660502c7a310"
|
||||
2.4.7:
|
||||
commit: "c166620d5f9706e54f9cd67044d42bf8583575d8"
|
||||
2.4.8:
|
||||
commit: "98c1527f778958b1a3322a4f026cfe2c421388aa"
|
||||
2.4.9:
|
||||
commit: "b21b27bb6e230901281bb524f84e177c937b48fd"
|
||||
2.4.10:
|
||||
commit: "08fe4695f6d5ef09789688481db478d0decbd5df"
|
||||
2.4.11:
|
||||
commit: "5c6a901f5118be54ae44affbd6881b14bc30e04a"
|
||||
2.4.12:
|
||||
commit: "6658165baedeee3939084ba4500de3de06bbc045"
|
||||
2.4.13:
|
||||
commit: "57403a89ab1d679523341d6a607d0b03e495ff35"
|
|
@ -0,0 +1,9 @@
|
|||
minor_release_name: "Seungeun"
|
||||
2.5.0:
|
||||
commit: "990131f546876d1461bac745e5cab3e60c78d038"
|
||||
2.5.1:
|
||||
commit: "0918444ab6ff934ba83393e294a135b1fc25bd0c"
|
||||
2.5.2:
|
||||
commit: "0918444ab6ff934ba83393e294a135b1fc25bd0c"
|
||||
2.5.3:
|
||||
commit: "0918444ab6ff934ba83393e294a135b1fc25bd0c"
|
|
@ -0,0 +1,21 @@
|
|||
minor_release_name: "HyunA"
|
||||
2.6.0:
|
||||
commit: "b161da1c1a1632725a44e998ff0d1872b3d5d184"
|
||||
2.6.1:
|
||||
commit: "1eae55e3bba335d41da0d21dfc383b838d9f0d03"
|
||||
2.6.2:
|
||||
commit: "dd3c83920b668466f2c053434bfd6be93bf32942"
|
||||
2.6.3:
|
||||
commit: "27f3ff6d085f42bdb67385f967db904022339d1d"
|
||||
2.6.4:
|
||||
commit: "5f8e73e6c714e9ca94a66f48d1b72fe516bbb0da"
|
||||
2.6.5:
|
||||
commit: "0fdd7669cced6c2b47f657e510bda03a053ee7ae"
|
||||
2.6.6:
|
||||
commit: "87cdb9987efe08b466f99f9ccb8b808131f9fbcd"
|
||||
2.6.7:
|
||||
commit: "0bdc4654bfb0f42d838e15c3d36dab0b4472db00"
|
||||
2.6.8:
|
||||
commit: "bdfb2a4a0b48362aabda7bb735296d83a02b932d"
|
||||
2.6.9:
|
||||
commit: "cb7a6d224152048176e6187ede6d60625961ab39"
|
|
@ -0,0 +1,25 @@
|
|||
minor_release_name: "Shanshan"
|
||||
2.7.0:
|
||||
commit: "8d7fb9a2c8be3f813ee5994be1818f9f81088faa"
|
||||
2.7.1:
|
||||
commit: "6885fbdeccb8b690fa0af59d8fd341e44803798f"
|
||||
2.7.2:
|
||||
commit: "4113d1761e28a7ee3b3cdabe4404cf3876f1fc84"
|
||||
2.7.3:
|
||||
commit: "1563a15abde175022b50fa085c6b9b19a6021c31"
|
||||
2.7.4:
|
||||
commit: "3e6bcc45d55446c6607664e407768391b47c5421"
|
||||
2.7.5:
|
||||
commit: "fa05c406606e269fb4153465611caeb71c12b486"
|
||||
2.7.6:
|
||||
commit: "47087b4288cbfa6000ca019a000f27ee5846d161"
|
||||
2.7.7:
|
||||
commit: "379ee49f1c61df9720346d3d021dea040587d54d"
|
||||
2.7.8:
|
||||
commit: "75bd823ad0cc24efecd1de193436a28dfaecd4f3"
|
||||
2.7.9:
|
||||
commit: "fb04dd507cee42092b889fe72cdf9975ea48e3b1"
|
||||
2.7.10:
|
||||
commit: "7fc879f77818371721e21c13e9df98796cf632de"
|
||||
2.7.11:
|
||||
commit: "44a2739a3b6e58cb90b7f7dfca2197834cf30464"
|
|
@ -0,0 +1,13 @@
|
|||
minor_release_name: "Haeun"
|
||||
2.8.0:
|
||||
commit: "25661f82af9338a024aae429cdafec7c86692aa5"
|
||||
2.8.1:
|
||||
commit: "1321fcb45ebe0291c9fd47ff2eb8cc329035acf3"
|
||||
2.8.2:
|
||||
commit: "e27a83bdc99a06a207c67c6f0034bc0a554c89af"
|
||||
2.8.3:
|
||||
commit: "6acab324dbd3594dcfbf944bfdfb5c8fe173354b"
|
||||
2.8.4:
|
||||
commit: "f7f1b1225e64b54d8962467182bddcc1de237f51"
|
||||
2.8.5:
|
||||
commit: "1dbc0b7fca05830d654076c74a91b6b74f470d5b"
|
|
@ -0,0 +1,23 @@
|
|||
minor_release_name: "Yaorenmao"
|
||||
2.9.0:
|
||||
commit: "8b4e9609e994d74506fd91471bd5a622b75b2f08"
|
||||
2.9.1:
|
||||
commit: "52a9faae90175841b2c259dd4677697e513e12f9"
|
||||
2.9.2:
|
||||
commit: "5cf7ca2e9bbf66082c4afb76b4033ff17c9cf8c8"
|
||||
2.9.3:
|
||||
commit: "e8c316f1992c3e5f171891272f32d959bb1fa4f0"
|
||||
2.9.4:
|
||||
commit: "e8a87cb8a5e2f63850ff3c02ed5aa8ee388460ed"
|
||||
2.9.5:
|
||||
commit: "09d3f103832bb7e26949a8f2df60c25851886bdc"
|
||||
2.9.6:
|
||||
commit: "9fb352cc6fe2bc41c56304e5ba941035fc1ac82d"
|
||||
2.9.7:
|
||||
commit: "f4a563f080f7dba336034feb1c0c42057f8d8d8c"
|
||||
2.9.8:
|
||||
commit: "2da9f154be240b8648d68a7eb2a3291738cfc93c"
|
||||
2.9.9:
|
||||
commit: "f7861c44b4a44b0cdd34e9f3f62530b8bf2837e3"
|
||||
2.9.10:
|
||||
commit: "22172d8b57df2ad1282f8d835183be45843fdd6a"
|
|
@ -0,0 +1,51 @@
|
|||
minor_release_name: "Yeonhee"
|
||||
3.0.0:
|
||||
commit: "f31c95228eb2dc01e661be928ffd881c063377da"
|
||||
notes:
|
||||
- "[Architecture] Switched to SQLite for main database"
|
||||
- "[Architecture] Switched to SQLite for artwork cache"
|
||||
- "[Feature] Added scrobble deletion from web interface"
|
||||
3.0.1:
|
||||
commit: "700b81217cb585df631d6f069243c56074cd1b71"
|
||||
notes:
|
||||
- "[Bugfix] Fixed upgrading imported scrobbles"
|
||||
3.0.2:
|
||||
commit: "4a8221f7a08f679b21c1fb619f03e5f922a1dc2b"
|
||||
notes:
|
||||
- "[Logging] Cleaned up output for waitress warnings"
|
||||
- "[Bugfix] Fixed exception in native API"
|
||||
3.0.3:
|
||||
commit: "1d9247fc724d7410b6e50d2cbfaa8f375d5e70af"
|
||||
notes:
|
||||
- "[Documentation] Added descriptions for native API endpoints"
|
||||
- "[Code Health] Made arguments for native API scrobbling explicit"
|
||||
- "[Bugfix] Fixed faulty entity type recognition for artists including the string 'artists'"
|
||||
- "[Bugfix] Fixed OS return codes"
|
||||
3.0.4:
|
||||
commit: "206ebd58ea204e0008f2c9bf72d76dd9918fec53"
|
||||
notes:
|
||||
- "[Feature] Enabled dual stack for web server"
|
||||
- "[Feature] Added better feedback to native API endpoints"
|
||||
- "[Bugfix] Fixed native API receiving superfluous keywords"
|
||||
- "[Bugfix] Fixed crash when importing scrobbles with artists with similar names"
|
||||
3.0.5:
|
||||
commit: "fe21894c5ecf3a53c9c5c00453abfc7f41c6a83e"
|
||||
notes:
|
||||
- "[Feature] Added notification system for web interface"
|
||||
- "[Bugfix] Fixed crash when encountering error in Lastfm import"
|
||||
3.0.6:
|
||||
commit: "b3d4cb7a153845d1f5a5eef67a6508754e338f2f"
|
||||
notes:
|
||||
- "[Performance] Implemented search in database"
|
||||
- "[Bugfix] Better parsing of featuring artists"
|
||||
- "[Bugfix] Fixed buffered output in Docker"
|
||||
- "[Bugfix] Fixed importing a Spotify file without path"
|
||||
- "[Bugfix] No longer releasing database lock during scrobble creation"
|
||||
- "[Distribution] Experimental arm64 image"
|
||||
3.0.7:
|
||||
commit: "62abc319303a6cb6463f7c27b6ef09b76fc67f86"
|
||||
notes:
|
||||
- "[Bugix] Improved signal handling"
|
||||
- "[Bugix] Fixed constant re-caching of all-time stats, significantly increasing page load speed"
|
||||
- "[Logging] Disabled cache information when cache is not used"
|
||||
- "[Distribution] Experimental arm/v7 image"
|
|
@ -0,0 +1,46 @@
|
|||
minor_release_name: "Soyeon"
|
||||
3.1.0:
|
||||
commit: "bfa553bed05d7dba33f611a44485d6cf460ba308"
|
||||
notes:
|
||||
- "[Architecture] Cleaned up legacy process control"
|
||||
- "[Architecture] Added proper exception framework to native API"
|
||||
- "[Feature] Implemented track title and artist name editing from web interface"
|
||||
- "[Feature] Implemented track and artist merging from web interface"
|
||||
- "[Feature] Implemented scrobble reparsing from web interface"
|
||||
- "[Performance] Adjusted cache sizes"
|
||||
- "[Logging] Added cache memory use information"
|
||||
- "[Technical] Bumped Python Version and various dependencies"
|
||||
3.1.1:
|
||||
commit: "20aae955b2263be07c56bafe4794f622117116ef"
|
||||
notes:
|
||||
- "[Bugfix] Fixed inclusion of custom css files"
|
||||
- "[Bugfix] Fixed list values in configuration"
|
||||
3.1.2:
|
||||
commit: "a0739306013cd9661f028fb5b2620cfa2d298aa4"
|
||||
notes:
|
||||
- "[Feature] Added remix artist parsing"
|
||||
- "[Feature] Added API debug mode"
|
||||
- "[Bugfix] Fixed leftover whitespaces when parsing titles"
|
||||
- "[Bugfix] Fixed handling of fallthrough values in config file"
|
||||
3.1.3:
|
||||
commit: "f3a04c79b1c37597cdf3cafcd95e3c923cd6a53f"
|
||||
notes:
|
||||
- "[Bugfix] Fixed infinite recursion with capitalized featuring delimiters"
|
||||
- "[Bugfix] Fixed favicon display"
|
||||
3.1.4:
|
||||
commit: "ef06f2262205c903e7c3060e2d2d52397f8ffc9d"
|
||||
notes:
|
||||
- "[Feature] Expanded information saved from Listenbrainz API"
|
||||
- "[Feature] Added import for Listenbrainz exports"
|
||||
- "[Bugfix] Sanitized artists and tracks with html-like structure"
|
||||
3.1.5:
|
||||
commit: "4330b0294bc0a01cdb841e2e3db370108da901db"
|
||||
notes:
|
||||
- "[Feature] Made image upload part of regular API"
|
||||
- "[Bugfix] Additional entity name sanitization"
|
||||
- "[Bugfix] Fixed image display on Safari"
|
||||
- "[Bugfix] Fixed entity editing on Firefox"
|
||||
- "[Bugfix] Made compatibile with SQLAlchemy 2.0"
|
||||
upcoming:
|
||||
notes:
|
||||
- "[Bugfix] Fixed configuration of time format"
|
|
@ -0,0 +1,2 @@
|
|||
docker build -t maloja . -f Containerfile
|
||||
docker run --rm -p 42010:42010 -v $PWD/testdata:/mlj -e MALOJA_DATA_DIRECTORY=/mlj maloja
|
|
@ -0,0 +1,2 @@
|
|||
podman build -t maloja . -f Containerfile
|
||||
podman run --rm -p 42010:42010 -v $PWD/testdata:/mlj -e MALOJA_DATA_DIRECTORY=/mlj maloja
|
|
@ -0,0 +1,36 @@
|
|||
# Contributor: Johannes Krattenmacher <maloja@dev.krateng.ch>
|
||||
# Maintainer: Johannes Krattenmacher <maloja@dev.krateng.ch>
|
||||
pkgname={{ tool.flit.module.name }}
|
||||
pkgver={{ project.version }}
|
||||
pkgrel=0
|
||||
pkgdesc="{{ project.description }}"
|
||||
url="{{ project.urls.homepage }}"
|
||||
arch="noarch"
|
||||
license="GPL-3.0"
|
||||
depends="{{ tool.osreqs.alpine.run | join(' ') }}"
|
||||
pkgusers=$pkgname
|
||||
pkggroups=$pkgname
|
||||
depends_dev="{{ tool.osreqs.alpine.build | join(' ') }}"
|
||||
makedepends="$depends_dev"
|
||||
source="
|
||||
$pkgname-$pkgver.tar.gz::{{ project.urls.repository }}/archive/refs/tags/v$pkgver.tar.gz
|
||||
"
|
||||
builddir="$srcdir"/$pkgname-$pkgver
|
||||
|
||||
|
||||
|
||||
build() {
|
||||
cd $builddir
|
||||
python3 -m build .
|
||||
pip3 install dist/*.tar.gz
|
||||
}
|
||||
|
||||
package() {
|
||||
mkdir -p /etc/$pkgname || return 1
|
||||
mkdir -p /var/lib/$pkgname || return 1
|
||||
mkdir -p /var/cache/$pkgname || return 1
|
||||
mkdir -p /var/logs/$pkgname || return 1
|
||||
}
|
||||
|
||||
# TODO
|
||||
sha512sums="a674eaaaa248fc2b315514d79f9a7a0bac6aa1582fe29554d9176e8b551e8aa3aa75abeebdd7713e9e98cc987e7bd57dc7a5e9a2fb85af98b9c18cb54de47bf7 $pkgname-${pkgver}.tar.gz"
|
|
@ -0,0 +1,40 @@
|
|||
FROM alpine:3.15
|
||||
# Python image includes two Python versions, so use base Alpine
|
||||
|
||||
# Based on the work of Jonathan Boeckel <jonathanboeckel1996@gmail.com>
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install run dependencies first
|
||||
RUN apk add --no-cache {{ tool.osreqs.alpine.run | join(' ') }}
|
||||
|
||||
# system pip could be removed after build, but apk then decides to also remove all its
|
||||
# python dependencies, even if they are explicitly installed as python packages
|
||||
# whut
|
||||
RUN \
|
||||
apk add py3-pip && \
|
||||
pip install wheel
|
||||
|
||||
|
||||
COPY ./requirements.txt ./requirements.txt
|
||||
|
||||
RUN \
|
||||
apk add --no-cache --virtual .build-deps {{ tool.osreqs.alpine.build | join(' ') }} && \
|
||||
pip install --no-cache-dir -r requirements.txt && \
|
||||
apk del .build-deps
|
||||
|
||||
|
||||
# no chance for caching below here
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pip install /usr/src/app
|
||||
|
||||
# Docker-specific configuration
|
||||
# defaulting to IPv4 is no longer necessary (default host is dual stack)
|
||||
ENV MALOJA_SKIP_SETUP=yes
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 42010
|
||||
# use exec form for better signal handling https://docs.docker.com/engine/reference/builder/#entrypoint
|
||||
ENTRYPOINT ["maloja", "run"]
|
|
@ -0,0 +1,4 @@
|
|||
{% include 'install/install_dependencies_alpine.sh.jinja' %}
|
||||
apk add py3-pip
|
||||
pip install wheel
|
||||
pip install malojaserver
|
|
@ -0,0 +1,4 @@
|
|||
{% include 'install/install_dependencies_debian.sh.jinja' %}
|
||||
apt install python3-pip
|
||||
pip install wheel
|
||||
pip install malojaserver
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
apk update
|
||||
apk add \
|
||||
{{ (tool.osreqs.alpine.build + tool.osreqs.alpine.run + tool.osreqs.alpine.opt) | join(' \\\n\t') }}
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
apt update
|
||||
apt install \
|
||||
{{ (tool.osreqs.debian.build + tool.osreqs.debian.run + tool.osreqs.debian.opt) | join(' \\\n\t') }}
|
|
@ -0,0 +1,3 @@
|
|||
{% for dep in project.dependencies -%}
|
||||
{{ dep }}
|
||||
{% endfor %}
|
|
@ -0,0 +1,3 @@
|
|||
{% for dep in project['optional-dependencies'].full -%}
|
||||
{{ dep }}
|
||||
{% endfor %}
|
|
@ -0,0 +1,907 @@
|
|||
{
|
||||
"info": {
|
||||
"_postman_id": "71b19d5e-78a1-4a4a-98b5-12cbf2f13fe6",
|
||||
"name": "Maloja",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Scrobbling",
|
||||
"item": [
|
||||
{
|
||||
"name": "Scrobble Native",
|
||||
"item": [
|
||||
{
|
||||
"name": "Query String",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": []
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{url}}/apis/mlj_1/newscrobble?key={{api_key}}&artist={{data.artist1}}&title={{data.title1}}&artist={{data.artist2}}",
|
||||
"host": [
|
||||
"{{url}}"
|
||||
],
|
||||
"path": [
|
||||
"apis",
|
||||
"mlj_1",
|
||||
"newscrobble"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "key",
|
||||
"value": "{{api_key}}"
|
||||
},
|
||||
{
|
||||
"key": "artist",
|
||||
"value": "{{data.artist1}}"
|
||||
},
|
||||
{
|
||||
"key": "title",
|
||||
"value": "{{data.title1}}"
|
||||
},
|
||||
{
|
||||
"key": "artist",
|
||||
"value": "{{data.artist2}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Query String Redirect",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{url}}/api/newscrobble?key={{api_key}}&artist={{data.artist1}}&title={{data.title1}}&artist={{data.artist2}}",
|
||||
"host": [
|
||||
"{{url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"newscrobble"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "key",
|
||||
"value": "{{api_key}}"
|
||||
},
|
||||
{
|
||||
"key": "artist",
|
||||
"value": "{{data.artist1}}"
|
||||
},
|
||||
{
|
||||
"key": "title",
|
||||
"value": "{{data.title1}}"
|
||||
},
|
||||
{
|
||||
"key": "artist",
|
||||
"value": "{{data.artist2}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Formdata",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "key",
|
||||
"value": "{{api_key}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "artist",
|
||||
"value": "{{data.artist1}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "title",
|
||||
"value": "{{data.title1}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "artist",
|
||||
"value": "{{data.artist2}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{url}}/apis/mlj_1/newscrobble",
|
||||
"host": [
|
||||
"{{url}}"
|
||||
],
|
||||
"path": [
|
||||
"apis",
|
||||
"mlj_1",
|
||||
"newscrobble"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Formdata Redirect",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "key",
|
||||
"value": "{{api_key}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "artist",
|
||||
"value": "{{data.artist1}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "title",
|
||||
"value": "{{data.title1}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "artist",
|
||||
"value": "{{data.artist2}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{url}}/api/newscrobble",
|
||||
"host": [
|
||||
"{{url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"newscrobble"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "JSON",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"name": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"key\": \"{{api_key}}\",\n \"artist\": \"{{data.artist1}}\",\n \"title\": \"{{data.title1}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{url}}/apis/mlj_1/newscrobble",
|
||||
"host": [
|
||||
"{{url}}"
|
||||
],
|
||||
"path": [
|
||||
"apis",
|
||||
"mlj_1",
|
||||
"newscrobble"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "JSON Multiartist",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"name": "Content-Type",
|
||||
"type": "text",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"key\": \"{{api_key}}\",\n \"artists\": [\"{{data.artist1}}\",\"{{data.artist2}}\"],\n \"title\": \"{{data.title1}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{url}}/apis/mlj_1/newscrobble",
|
||||
"host": [
|
||||
"{{url}}"
|
||||
],
|
||||
"path": [
|
||||
"apis",
|
||||
"mlj_1",
|
||||
"newscrobble"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "JSON Redirect",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"name": "Content-Type",
|
||||
"type": "text",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"key\": \"{{api_key}}\",\n \"artist\": \"{{data.artist1}}\",\n \"title\": \"{{data.title1}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{url}}/api/newscrobble",
|
||||
"host": [
|
||||
"{{url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"newscrobble"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Scrobble Listenbrainz",
|
||||
"item": [
|
||||
{
|
||||
"name": "JSON",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"name": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "token {{api_key}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"listen_type\":\"single\",\n\t\"payload\":[\n\t\t{\n\t\t\t\"track_metadata\":{\n\t\t\t\t\"artist_name\":\"{{data.artist1}}\",\n\t\t\t\t\"track_name\":\"{{data.title1}}\"\n\t\t\t}\n\t\t}\n\t]\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{url}}/apis/listenbrainz/1/submit-listens",
|
||||
"host": [
|
||||
"{{url}}"
|
||||
],
|
||||
"path": [
|
||||
"apis",
|
||||
"listenbrainz",
|
||||
"1",
|
||||
"submit-listens"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "JSON Redirect",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"name": "Content-Type",
|
||||
"type": "text",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"type": "text",
|
||||
"value": "token {{api_key}}"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"listen_type\":\"single\",\n\t\"payload\":[\n\t\t{\n\t\t\t\"track_metadata\":{\n\t\t\t\t\"artist_name\":\"{{data.artist1}}\",\n\t\t\t\t\"track_name\":\"{{data.title1}}\"\n\t\t\t}\n\t\t}\n\t]\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{url}}/api/s/listenbrainz/1/submit-listens",
|
||||
"host": [
|
||||
"{{url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"s",
|
||||
"listenbrainz",
|
||||
"1",
|
||||
"submit-listens"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Scrobble Audioscrobbler",
|
||||
"item": [
|
||||
{
|
||||
"name": "JSON",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
""
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
},
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"exec": [
|
||||
""
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"name": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"method\":\"track.scrobble\",\n\t\"artist\":\"{{data.artist1}}\",\n\t\"track\":\"{{data.title1}}\",\n\t\"sk\":\"{{session_key}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{url}}/apis/audioscrobbler/2.0/",
|
||||
"host": [
|
||||
"{{url}}"
|
||||
],
|
||||
"path": [
|
||||
"apis",
|
||||
"audioscrobbler",
|
||||
"2.0",
|
||||
""
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "JSON Redirect",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
""
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"name": "Content-Type",
|
||||
"type": "text",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"method\":\"track.scrobble\",\n\t\"artist\":\"{{data.artist1}}\",\n\t\"track\":\"{{data.title1}}\",\n\t\"sk\":\"{{session_key}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{url}}/api/s/audioscrobbler/2.0/",
|
||||
"host": [
|
||||
"{{url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"s",
|
||||
"audioscrobbler",
|
||||
"2.0",
|
||||
""
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Authorize",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"var data = JSON.parse(responseBody);",
|
||||
"pm.environment.set(\"session_key\", data.session.key);",
|
||||
"tests[\"gotkey\"] = true;"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"name": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n\t\"method\":\"auth.getMobileSession\",\n\t\"authToken\":\"abc\",\n\t\"username\":\"someguy\",\n\t\"password\":\"{{api_key}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{url}}/apis/audioscrobbler/2.0/",
|
||||
"host": [
|
||||
"{{url}}"
|
||||
],
|
||||
"path": [
|
||||
"apis",
|
||||
"audioscrobbler",
|
||||
"2.0",
|
||||
""
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Scrobble Audioscrobbler Legacy",
|
||||
"item": [
|
||||
{
|
||||
"name": "Authorize",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"var data = responseBody.split(\"\\n\");",
|
||||
"",
|
||||
"pm.environment.set(\"session_key\", data[1]);",
|
||||
"pm.environment.set(\"scrobble_url\", data[3]);"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
},
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"exec": [
|
||||
"apikey = pm.variables.get(\"api_key\");",
|
||||
"ts = pm.variables.get(\"data.timestamp1\");",
|
||||
"",
|
||||
"token = CryptoJS.MD5(CryptoJS.MD5(apikey) + ts).toString()",
|
||||
"pm.environment.set(\"legacy_token\", token);"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"protocolProfileBehavior": {
|
||||
"disableBodyPruning": true
|
||||
},
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": ""
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{url}}/apis/audioscrobbler_legacy/?hs=true&t={{data.timestamp1}}&a={{legacy_token}}",
|
||||
"host": [
|
||||
"{{url}}"
|
||||
],
|
||||
"path": [
|
||||
"apis",
|
||||
"audioscrobbler_legacy",
|
||||
""
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "hs",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"key": "t",
|
||||
"value": "{{data.timestamp1}}"
|
||||
},
|
||||
{
|
||||
"key": "a",
|
||||
"value": "{{legacy_token}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Scrobble",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{scrobble_url}}?s={{session_key}}&a[0]={{data.artist1}}&t[0]={{data.title1}}&a[1]={{data.artist2}}&t[1]={{data.title2}}&i[0]={{data.timestamp1}}&i[1]={{data.timestamp2}}",
|
||||
"host": [
|
||||
"{{scrobble_url}}"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "s",
|
||||
"value": "{{session_key}}"
|
||||
},
|
||||
{
|
||||
"key": "a[0]",
|
||||
"value": "{{data.artist1}}"
|
||||
},
|
||||
{
|
||||
"key": "t[0]",
|
||||
"value": "{{data.title1}}"
|
||||
},
|
||||
{
|
||||
"key": "a[1]",
|
||||
"value": "{{data.artist2}}"
|
||||
},
|
||||
{
|
||||
"key": "t[1]",
|
||||
"value": "{{data.title2}}"
|
||||
},
|
||||
{
|
||||
"key": "i[0]",
|
||||
"value": "{{data.timestamp1}}"
|
||||
},
|
||||
{
|
||||
"key": "i[1]",
|
||||
"value": "{{data.timestamp2}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Metadata",
|
||||
"item": [
|
||||
{
|
||||
"name": "Spotify",
|
||||
"item": [
|
||||
{
|
||||
"name": "Authorize",
|
||||
"event": [
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"exec": [
|
||||
"apiid = pm.collectionVariables.get(\"external.spotify.api_id\");",
|
||||
"apisecret = pm.collectionVariables.get(\"external.spotify.api_secret\");",
|
||||
"",
|
||||
"authb64 = new Buffer(apiid + ':' + apisecret).toString('base64');",
|
||||
"pm.environment.set(\"authb64\", authb64);",
|
||||
""
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
},
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"var data = JSON.parse(responseBody);",
|
||||
"pm.collectionVariables.set(\"external.spotify.access_token\", data.access_token);"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Basic {{authb64}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "urlencoded",
|
||||
"urlencoded": [
|
||||
{
|
||||
"key": "grant_type",
|
||||
"value": "client_credentials",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "https://accounts.spotify.com/api/token",
|
||||
"protocol": "https",
|
||||
"host": [
|
||||
"accounts",
|
||||
"spotify",
|
||||
"com"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Track Info Old",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "https://api.spotify.com/v1/search?type=track&access_token={{external.spotify.access_token}}&q=artist:{{data.artist3}} track:{{data.title3}}",
|
||||
"protocol": "https",
|
||||
"host": [
|
||||
"api",
|
||||
"spotify",
|
||||
"com"
|
||||
],
|
||||
"path": [
|
||||
"v1",
|
||||
"search"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "type",
|
||||
"value": "track"
|
||||
},
|
||||
{
|
||||
"key": "access_token",
|
||||
"value": "{{external.spotify.access_token}}"
|
||||
},
|
||||
{
|
||||
"key": "q",
|
||||
"value": "artist:{{data.artist3}} track:{{data.title3}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Track Info New",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{external.spotify.access_token}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "https://api.spotify.com/v1/search?type=track&q=artist:{{data.artist3}}%20track:{{data.title3}}",
|
||||
"protocol": "https",
|
||||
"host": [
|
||||
"api",
|
||||
"spotify",
|
||||
"com"
|
||||
],
|
||||
"path": [
|
||||
"v1",
|
||||
"search"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "type",
|
||||
"value": "track"
|
||||
},
|
||||
{
|
||||
"key": "q",
|
||||
"value": "artist:{{data.artist3}}%20track:{{data.title3}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Scrobbleforward",
|
||||
"item": [
|
||||
{
|
||||
"name": "Last.fm",
|
||||
"item": [
|
||||
{
|
||||
"name": "Authorize",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "urlencoded",
|
||||
"urlencoded": []
|
||||
},
|
||||
"url": {
|
||||
"raw": "https://ws.audioscrobbler.com/2.0/?method=auth.getMobileSession&username={{external.lastfm.username}}&password={{external.lastfm.password}}&api_key={{external.lastfm.api_key}}&api_sig=TODO",
|
||||
"protocol": "https",
|
||||
"host": [
|
||||
"ws",
|
||||
"audioscrobbler",
|
||||
"com"
|
||||
],
|
||||
"path": [
|
||||
"2.0",
|
||||
""
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "method",
|
||||
"value": "auth.getMobileSession"
|
||||
},
|
||||
{
|
||||
"key": "username",
|
||||
"value": "{{external.lastfm.username}}"
|
||||
},
|
||||
{
|
||||
"key": "password",
|
||||
"value": "{{external.lastfm.password}}"
|
||||
},
|
||||
{
|
||||
"key": "api_key",
|
||||
"value": "{{external.lastfm.api_key}}"
|
||||
},
|
||||
{
|
||||
"key": "api_sig",
|
||||
"value": "TODO"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"event": [
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"var current_timestamp = Math.floor(Date.now() / 1000);",
|
||||
"pm.collectionVariables.set(\"data.timestamp1\", current_timestamp);",
|
||||
"pm.collectionVariables.set(\"data.timestamp2\", current_timestamp - 200);"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
""
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "url",
|
||||
"value": "http://localhost:42010"
|
||||
},
|
||||
{
|
||||
"key": "api_key",
|
||||
"value": "localdevtestkey"
|
||||
},
|
||||
{
|
||||
"key": "session_key",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "data.artist1",
|
||||
"value": "EXID ft. Jeremy Soule"
|
||||
},
|
||||
{
|
||||
"key": "data.artist2",
|
||||
"value": "BLACKPINK ft. Tzuyu"
|
||||
},
|
||||
{
|
||||
"key": "data.artist3",
|
||||
"value": "TWICE"
|
||||
},
|
||||
{
|
||||
"key": "data.title1",
|
||||
"value": "Why is the Rum gone?"
|
||||
},
|
||||
{
|
||||
"key": "data.title2",
|
||||
"value": "POP/STARS"
|
||||
},
|
||||
{
|
||||
"key": "data.title3",
|
||||
"value": "One in a Million"
|
||||
},
|
||||
{
|
||||
"key": "data.timestamp1",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "data.timestamp2",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "external.spotify.api_id",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "external.spotify.api_secret",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "external.spotify.access_token",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "external.lastfm.username",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "external.lastfm.password",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "external.lastfm.api_key",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "external.lastfm.secret",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import threading
|
||||
import subprocess
|
||||
import time
|
||||
import requests
|
||||
import os
|
||||
|
||||
ACTIVE = True
|
||||
|
||||
build_cmd = ["docker","build","-t","maloja",".","-f","Containerfile"]
|
||||
subprocess.run(build_cmd)
|
||||
|
||||
common_prc = (
|
||||
["docker","run","--rm","-v",f"{os.path.abspath('./testdata')}:/mlj","-e","MALOJA_DATA_DIRECTORY=/mlj"],
|
||||
["maloja"]
|
||||
)
|
||||
|
||||
servers = [
|
||||
{'port': 42010},
|
||||
{'port': 42011, 'extraargs':["--memory=1g"]},
|
||||
{'port': 42012, 'extraargs':["--memory=500m"]}
|
||||
]
|
||||
for s in servers:
|
||||
cmd = common_prc[0] + ["-p",f"{s['port']}:42010"] + s.get('extraargs',[]) + common_prc[1]
|
||||
print(cmd)
|
||||
t = threading.Thread(target=subprocess.run,args=(cmd,))
|
||||
s['thread'] = t
|
||||
t.daemon = True
|
||||
t.start()
|
||||
time.sleep(5)
|
||||
|
||||
time.sleep(5)
|
||||
while ACTIVE:
|
||||
time.sleep(1)
|
||||
try:
|
||||
for s in servers:
|
||||
requests.get(f"http://localhost:{s['port']}")
|
||||
except KeyboardInterrupt:
|
||||
ACTIVE = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for s in servers:
|
||||
s['thread'].join()
|
|
@ -0,0 +1,33 @@
|
|||
import toml
|
||||
import os
|
||||
import jinja2
|
||||
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader('dev/templates'),
|
||||
autoescape=jinja2.select_autoescape(['html', 'xml']),
|
||||
keep_trailing_newline=True
|
||||
)
|
||||
|
||||
with open("pyproject.toml") as filed:
|
||||
data = toml.load(filed)
|
||||
|
||||
templatedir = "./dev/templates"
|
||||
|
||||
for root,dirs,files in os.walk(templatedir):
|
||||
|
||||
reldirpath = os.path.relpath(root,start=templatedir)
|
||||
for f in files:
|
||||
|
||||
relfilepath = os.path.join(reldirpath,f)
|
||||
|
||||
if not f.endswith('.jinja'): continue
|
||||
|
||||
srcfile = os.path.join(root,f)
|
||||
trgfile = os.path.join(reldirpath,f.replace(".jinja",""))
|
||||
|
||||
|
||||
template = env.get_template(relfilepath)
|
||||
result = template.render(**data)
|
||||
|
||||
with open(trgfile,"w") as filed:
|
||||
filed.write(result)
|
|
@ -0,0 +1,9 @@
|
|||
ICON_DIR=./maloja/web/static/png;
|
||||
SCROBBLER_DIR=./auxiliary/chromium_scrobbler;
|
||||
|
||||
convert $ICON_DIR/favicon_large.png -resize 256 $SCROBBLER_DIR/maloja-scrobbler/icon256.png
|
||||
convert $ICON_DIR/favicon_large.png -resize 128 $SCROBBLER_DIR/maloja-scrobbler/icon128.png
|
||||
convert $ICON_DIR/favicon_large.png -resize 48 $SCROBBLER_DIR/maloja-scrobbler/icon48.png
|
||||
convert $ICON_DIR/favicon_large.png -background none -resize 280 -gravity center -extent 440x280 -background "#232327" -flatten $SCROBBLER_DIR/tile.png
|
||||
rm $SCROBBLER_DIR/maloja-scrobbler.zip
|
||||
zip $SCROBBLER_DIR/maloja-scrobbler.zip $SCROBBLER_DIR/maloja-scrobbler/* $SCROBBLER_DIR/maloja-scrobbler/*/*
|
|
@ -0,0 +1,53 @@
|
|||
import os
|
||||
import subprocess as sp
|
||||
import yaml
|
||||
|
||||
FOLDER = "dev/releases"
|
||||
|
||||
releases = {}
|
||||
for f in os.listdir(FOLDER):
|
||||
if f == "branch.yml": continue
|
||||
#maj,min = (int(i) for i in f.split('.')[:2])
|
||||
|
||||
with open(os.path.join(FOLDER,f)) as fd:
|
||||
data = yaml.safe_load(fd)
|
||||
|
||||
name = data.pop('minor_release_name')
|
||||
|
||||
for tag in data:
|
||||
tagtup = tuple(int(i) for i in tag.split('.'))
|
||||
releases[tagtup] = data[tag]
|
||||
|
||||
# this is a bit dirty, works on our data
|
||||
if len(tagtup)<3 or tagtup[2] == 0: releases[tagtup]['name'] = name
|
||||
|
||||
|
||||
for version in releases:
|
||||
|
||||
info = releases[version]
|
||||
version = '.'.join(str(v) for v in version)
|
||||
msg = [
|
||||
f"Version {version}" + (f" '{info.get('name')}'" if info.get('name') else ''),
|
||||
*([""] if info.get('notes') else []),
|
||||
*[f"* {n}" for n in info.get('notes',[])]
|
||||
]
|
||||
|
||||
|
||||
cmd = [
|
||||
'git','tag','--force',
|
||||
'-a',f'v{version}',
|
||||
'-m',
|
||||
'\n'.join(msg),
|
||||
info['commit']
|
||||
]
|
||||
|
||||
try:
|
||||
prev_tag = sp.check_output(["git","show",f'v{maj}.{min}.{hot}']).decode()
|
||||
prev_tag_commit = prev_tag.split('\n')[6].split(" ")[1]
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
assert prev_tag_commit == info['commit']
|
||||
|
||||
print(cmd)
|
||||
sp.run(cmd)
|
|
@ -0,0 +1,20 @@
|
|||
services:
|
||||
maloja:
|
||||
# from dockerhub
|
||||
image: "krateng/maloja:latest"
|
||||
# or built locally
|
||||
#build:
|
||||
# context: .
|
||||
# dockerfile: ./Containerfile
|
||||
ports:
|
||||
- "42010:42010"
|
||||
# different directories for configuration, state and logs
|
||||
volumes:
|
||||
- "$PWD/config:/etc/maloja"
|
||||
- "$PWD/data:/var/lib/maloja"
|
||||
- "$PWD/logs:/var/log/maloja"
|
||||
#you can also have everything together instead:
|
||||
#volumes:
|
||||
#- "$PWD/data:/data"
|
||||
#environment:
|
||||
#- "MALOJA_DATA_DIRECTORY=/data"
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env sh
|
||||
apk update
|
||||
apk add \
|
||||
gcc \
|
||||
g++ \
|
||||
python3-dev \
|
||||
libxml2-dev \
|
||||
libxslt-dev \
|
||||
libffi-dev \
|
||||
libc-dev \
|
||||
py3-pip \
|
||||
linux-headers \
|
||||
python3 \
|
||||
py3-lxml \
|
||||
tzdata \
|
||||
vips
|
||||
|
||||
apk add py3-pip
|
||||
pip install wheel
|
||||
pip install malojaserver
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env sh
|
||||
apt update
|
||||
apt install \
|
||||
python3-pip \
|
||||
python3
|
||||
|
||||
apt install python3-pip
|
||||
pip install wheel
|
||||
pip install malojaserver
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env sh
|
||||
apk update
|
||||
apk add \
|
||||
gcc \
|
||||
g++ \
|
||||
python3-dev \
|
||||
libxml2-dev \
|
||||
libxslt-dev \
|
||||
libffi-dev \
|
||||
libc-dev \
|
||||
py3-pip \
|
||||
linux-headers \
|
||||
python3 \
|
||||
py3-lxml \
|
||||
tzdata \
|
||||
vips
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env sh
|
||||
apt update
|
||||
apt install \
|
||||
python3-pip \
|
||||
python3
|
|
@ -1,4 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
apk add python3 python3-dev gcc libxml2-dev libxslt-dev py3-pip libc-dev linux-headers
|
||||
pip3 install psutil
|
||||
pip3 install malojaserver
|
|
@ -1,5 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
apt update
|
||||
apt install python3 python3-pip
|
||||
pip3 install psutil
|
||||
pip3 install malojaserver
|
|
@ -1 +1,4 @@
|
|||
from . import globalconf
|
||||
# monkey patching
|
||||
from .pkg_global import monkey
|
||||
# configuration before all else
|
||||
from .pkg_global import conf
|
||||
|
|
|
@ -0,0 +1,184 @@
|
|||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from setproctitle import setproctitle
|
||||
from ipaddress import ip_address
|
||||
|
||||
from doreah.control import mainfunction
|
||||
from doreah.io import col
|
||||
from doreah.logging import log
|
||||
|
||||
from . import __pkginfo__ as pkginfo
|
||||
from .pkg_global import conf
|
||||
from .proccontrol import tasks
|
||||
from .setup import setup
|
||||
from .dev import generate, apidebug
|
||||
|
||||
|
||||
|
||||
def print_header_info():
|
||||
print()
|
||||
#print("#####")
|
||||
print(col['yellow']("Maloja"),f"v{pkginfo.VERSION}")
|
||||
print(pkginfo.HOMEPAGE)
|
||||
#print("#####")
|
||||
print()
|
||||
|
||||
|
||||
|
||||
def get_instance():
|
||||
try:
|
||||
return int(subprocess.check_output(["pidof","maloja"]))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_instance_supervisor():
|
||||
try:
|
||||
return int(subprocess.check_output(["pidof","maloja_supervisor"]))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def restart():
|
||||
if stop():
|
||||
start()
|
||||
else:
|
||||
print(col["red"]("Could not stop Maloja!"))
|
||||
|
||||
def start():
|
||||
if get_instance_supervisor() is not None:
|
||||
print("Maloja is already running.")
|
||||
else:
|
||||
print_header_info()
|
||||
setup()
|
||||
try:
|
||||
#p = subprocess.Popen(["python3","-m","maloja.server"],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
sp = subprocess.Popen(["python3","-m","maloja","supervisor"],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
print(col["green"]("Maloja started!"))
|
||||
|
||||
port = conf.malojaconfig["PORT"]
|
||||
|
||||
print("Visit your server address (Port " + str(port) + ") to see your web interface. Visit /admin_setup to get started.")
|
||||
print("If you're installing this on your local machine, these links should get you there:")
|
||||
print("\t" + col["blue"]("http://localhost:" + str(port)))
|
||||
print("\t" + col["blue"]("http://localhost:" + str(port) + "/admin_setup"))
|
||||
return True
|
||||
except Exception:
|
||||
print("Error while starting Maloja.")
|
||||
return False
|
||||
|
||||
|
||||
def stop():
|
||||
|
||||
for attempt in [(signal.SIGTERM,2),(signal.SIGTERM,5),(signal.SIGKILL,3),(signal.SIGKILL,5)]:
|
||||
|
||||
pid_sv = get_instance_supervisor()
|
||||
pid = get_instance()
|
||||
|
||||
if pid is None and pid_sv is None:
|
||||
print("Maloja stopped!")
|
||||
return True
|
||||
|
||||
if pid_sv is not None:
|
||||
os.kill(pid_sv,attempt[0])
|
||||
if pid is not None:
|
||||
os.kill(pid,attempt[0])
|
||||
|
||||
time.sleep(attempt[1])
|
||||
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
print("Maloja stopped!")
|
||||
return True
|
||||
|
||||
def onlysetup():
|
||||
print_header_info()
|
||||
setup()
|
||||
print("Setup complete!")
|
||||
|
||||
def run_server():
|
||||
print_header_info()
|
||||
setup()
|
||||
setproctitle("maloja")
|
||||
from . import server
|
||||
server.run_server()
|
||||
|
||||
def run_supervisor():
|
||||
setproctitle("maloja_supervisor")
|
||||
while True:
|
||||
log("Maloja is not running, starting...",module="supervisor")
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["python3", "-m", "maloja","run"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception as e:
|
||||
log("Error starting Maloja: " + str(e),module="supervisor")
|
||||
else:
|
||||
try:
|
||||
process.wait()
|
||||
except Exception as e:
|
||||
log("Maloja crashed: " + str(e),module="supervisor")
|
||||
|
||||
def debug():
|
||||
os.environ["MALOJA_DEV_MODE"] = 'true'
|
||||
conf.malojaconfig.load_environment()
|
||||
direct()
|
||||
|
||||
def print_info():
|
||||
print_header_info()
|
||||
print(col['lightblue']("Configuration Directory:"),conf.dir_settings['config'])
|
||||
print(col['lightblue']("Data Directory: "),conf.dir_settings['state'])
|
||||
print(col['lightblue']("Log Directory: "),conf.dir_settings['logs'])
|
||||
print(col['lightblue']("Network: "),f"Dual Stack, Port {conf.malojaconfig['port']}" if conf.malojaconfig['host'] == "*" else f"IPv{ip_address(conf.malojaconfig['host']).version}, Port {conf.malojaconfig['port']}")
|
||||
print(col['lightblue']("Timezone: "),f"UTC{conf.malojaconfig['timezone']:+d}")
|
||||
print()
|
||||
try:
|
||||
import pkg_resources
|
||||
for pkg in ("sqlalchemy","waitress","bottle","doreah","jinja2"):
|
||||
print(col['cyan'] (f"{pkg}:".ljust(13)),pkg_resources.get_distribution(pkg).version)
|
||||
except ImportError:
|
||||
print("Could not determine dependency versions.")
|
||||
print()
|
||||
|
||||
@mainfunction({"l":"level","v":"version","V":"version"},flags=['version','include_images'],shield=True)
|
||||
def main(*args,**kwargs):
|
||||
|
||||
actions = {
|
||||
# server
|
||||
"start":start,
|
||||
"restart":restart,
|
||||
"stop":stop,
|
||||
"run":run_server,
|
||||
"supervisor":run_supervisor,
|
||||
"debug":debug,
|
||||
"setup":onlysetup,
|
||||
# admin scripts
|
||||
"import":tasks.import_scrobbles, # maloja import /x/y.csv
|
||||
"backup":tasks.backup, # maloja backup --targetfolder /x/y --include_images
|
||||
"generate":generate.generate_scrobbles, # maloja generate 400
|
||||
"export":tasks.export, # maloja export
|
||||
"apidebug":apidebug.run, # maloja apidebug
|
||||
# aux
|
||||
"info":print_info
|
||||
}
|
||||
|
||||
if "version" in kwargs:
|
||||
print(info.VERSION)
|
||||
return True
|
||||
else:
|
||||
try:
|
||||
action, *args = args
|
||||
action = actions[action]
|
||||
except (ValueError, KeyError):
|
||||
print("Valid commands: " + " ".join(a for a in actions))
|
||||
return False
|
||||
|
||||
return action(*args,**kwargs)
|
|
@ -1,37 +1,11 @@
|
|||
name = "maloja"
|
||||
desc = "Self-hosted music scrobble database"
|
||||
author = {
|
||||
"name":"Johannes Krattenmacher",
|
||||
"email":"maloja@krateng.dev",
|
||||
"github": "krateng"
|
||||
}
|
||||
version = 2,5,0
|
||||
versionstr = ".".join(str(n) for n in version)
|
||||
links = {
|
||||
"pypi":"malojaserver",
|
||||
"github":"maloja"
|
||||
}
|
||||
# This file has now been slighly repurposed and will simply give other parts of
|
||||
# the package access to some global meta-information about itself
|
||||
|
||||
requires = [
|
||||
"bottle>=0.12.16",
|
||||
"waitress>=1.3",
|
||||
"doreah>=1.6.3",
|
||||
"nimrodel>=0.6.3",
|
||||
"setproctitle>=1.1.10",
|
||||
"wand>=0.5.4",
|
||||
"lesscpy>=0.13",
|
||||
"jinja2>2.11",
|
||||
"lru-dict>=1.1.6"
|
||||
]
|
||||
resources = [
|
||||
"web/*/*/*",
|
||||
"web/*/*",
|
||||
"web/*",
|
||||
"static/*/*",
|
||||
"data_files/*/*",
|
||||
"data_files/*/*/*"
|
||||
]
|
||||
# you know what f*ck it
|
||||
# this is hardcoded for now because of that damn project / package name discrepancy
|
||||
# i'll fix it one day
|
||||
VERSION = "3.1.5"
|
||||
HOMEPAGE = "https://github.com/krateng/maloja"
|
||||
|
||||
commands = {
|
||||
"maloja":"proccontrol.control:main"
|
||||
}
|
||||
|
||||
USER_AGENT = f"Maloja/{VERSION} ( {HOMEPAGE} )"
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
from ._apikeys import apikeystore
|
||||
|
||||
|
||||
import copy
|
||||
from bottle import redirect, request, response
|
||||
from urllib.parse import urlencode
|
||||
|
||||
|
||||
|
||||
def init_apis(server):
|
||||
|
||||
from . import native_v1
|
||||
from .audioscrobbler import Audioscrobbler
|
||||
from .audioscrobbler_legacy import AudioscrobblerLegacy
|
||||
from .listenbrainz import Listenbrainz
|
||||
|
||||
native_apis = [
|
||||
native_v1.api
|
||||
]
|
||||
standardized_apis = [
|
||||
Listenbrainz(),
|
||||
Audioscrobbler(),
|
||||
AudioscrobblerLegacy()
|
||||
]
|
||||
|
||||
for api in native_apis:
|
||||
api.mount(server=server,path="apis/"+api.__apipath__)
|
||||
|
||||
for api in standardized_apis:
|
||||
aliases = api.__aliases__
|
||||
canonical = aliases[0]
|
||||
api.nimrodelapi.mount(server=server,path="apis/" + canonical)
|
||||
|
||||
# redirects
|
||||
for alias in aliases[1:]:
|
||||
altpath = "/apis/" + alias + "/<pth:path>"
|
||||
altpath_empty = "/apis/" + alias
|
||||
altpath_empty_cl = "/apis/" + alias + "/"
|
||||
|
||||
def alias_api(pth=""):
|
||||
redirect("/apis/" + canonical + "/" + pth + "?" + urlencode(request.query))
|
||||
|
||||
server.get(altpath)(alias_api)
|
||||
server.post(altpath)(alias_api)
|
||||
server.get(altpath_empty)(alias_api)
|
||||
server.post(altpath_empty)(alias_api)
|
||||
server.get(altpath_empty_cl)(alias_api)
|
||||
server.post(altpath_empty_cl)(alias_api)
|
||||
|
||||
def invalid_api(pth=''):
|
||||
response.status = 404
|
||||
return {"error":"Invalid API"}
|
||||
|
||||
server.get("/apis/<pth:path>")(invalid_api)
|
||||
server.post("/apis/<pth:path>")(invalid_api)
|
||||
|
||||
server.get("/apis")(invalid_api)
|
||||
server.post("/apis")(invalid_api)
|
|
@ -0,0 +1,29 @@
|
|||
### API KEYS
|
||||
### symmetric keys are fine since we hopefully use HTTPS
|
||||
|
||||
from doreah.keystore import KeyStore
|
||||
from doreah.logging import log
|
||||
|
||||
from ..pkg_global.conf import data_dir
|
||||
|
||||
apikeystore = KeyStore(file=data_dir['clients']("apikeys.yml"),save_endpoint="/apis/mlj_1/apikeys")
|
||||
|
||||
|
||||
from .. import upgrade
|
||||
upgrade.upgrade_apikeys()
|
||||
|
||||
|
||||
# skip regular authentication if api key is present in request
|
||||
# an api key now ONLY permits scrobbling tracks, no other admin tasks
|
||||
def api_key_correct(request,args,kwargs):
|
||||
if "key" in kwargs:
|
||||
apikey = kwargs.pop("key")
|
||||
elif "apikey" in kwargs:
|
||||
apikey = kwargs.pop("apikey")
|
||||
else: return False
|
||||
|
||||
client = apikeystore.check_and_identify_key(apikey)
|
||||
if client:
|
||||
return {'client':client}
|
||||
else:
|
||||
return False
|
|
@ -0,0 +1,98 @@
|
|||
from nimrodel import EAPI as API
|
||||
from nimrodel import Multi
|
||||
|
||||
from ._exceptions import *
|
||||
|
||||
from copy import deepcopy
|
||||
from types import FunctionType
|
||||
import sys
|
||||
import datetime
|
||||
|
||||
from doreah.logging import log
|
||||
|
||||
from bottle import response
|
||||
|
||||
from ..cleanup import CleanerAgent
|
||||
from .. import database
|
||||
|
||||
__logmodulename__ = "apis"
|
||||
|
||||
|
||||
#def singleton(cls):
|
||||
# return cls()
|
||||
|
||||
|
||||
|
||||
cla = CleanerAgent()
|
||||
|
||||
class APIHandler:
|
||||
# make these classes singletons
|
||||
_instance = None
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if not isinstance(cls._instance, cls):
|
||||
cls._instance = object.__new__(cls, *args, **kwargs)
|
||||
return cls._instance
|
||||
|
||||
def __init_subclass__(cls):
|
||||
# Copy the handle function so we can have a unique docstring
|
||||
sf = cls.__base__.wrapper
|
||||
cls.wrapper = FunctionType(sf.__code__,sf.__globals__,sf.__name__,sf.__defaults__,sf.__closure__)
|
||||
cls.wrapper.__annotations__ = sf.__annotations__
|
||||
# need to copy annotations so nimrodel properly passes path argument
|
||||
|
||||
# create docstring
|
||||
doc = "Accepts requests according to the <a href='{link}'>{name} Standard</a>"
|
||||
cls.wrapper.__doc__ = doc.format(name=cls.__apiname__,link=cls.__doclink__)
|
||||
|
||||
def __init__(self):
|
||||
self.init()
|
||||
|
||||
# creates a rump api object that exposes one generic endpoint
|
||||
# we don't want the api explorer to show the different structures of
|
||||
# third party apis, just mention they exist
|
||||
self.nimrodelapi = API(delay=True)
|
||||
self.nimrodelapi.get("{path}",pass_headers=True)(self.wrapper)
|
||||
self.nimrodelapi.post("{path}",pass_headers=True)(self.wrapper)
|
||||
self.nimrodelapi.get("",pass_headers=True)(self.wrapper)
|
||||
self.nimrodelapi.post("",pass_headers=True)(self.wrapper)
|
||||
|
||||
|
||||
def wrapper(self,path:Multi=[],**keys):
|
||||
log(f"{self.__apiname__} API request: {path}")# + " | Keys: " + str({k:keys.get(k) for k in keys}))
|
||||
|
||||
try:
|
||||
response.status,result = self.handle(path,keys)
|
||||
except Exception:
|
||||
exceptiontype = sys.exc_info()[0]
|
||||
if exceptiontype in self.errors:
|
||||
response.status,result = self.errors[exceptiontype]
|
||||
log(f"Error with {self.__apiname__} API: {exceptiontype} (Request: {path})")
|
||||
else:
|
||||
response.status,result = 500,{"status":"Unknown error","code":500}
|
||||
log(f"Unhandled Exception with {self.__apiname__} API: {exceptiontype} (Request: {path})")
|
||||
|
||||
return result
|
||||
#else:
|
||||
# result = {"error":"Invalid scrobble protocol"}
|
||||
# response.status = 500
|
||||
|
||||
|
||||
def handle(self,path,keys):
|
||||
|
||||
try:
|
||||
methodname = self.get_method(path,keys)
|
||||
method = self.methods[methodname]
|
||||
except Exception:
|
||||
log("Could not find a handler for method " + str(methodname) + " in API " + self.__apiname__,module="debug")
|
||||
log("Keys: " + str(keys),module="debug")
|
||||
raise InvalidMethodException()
|
||||
return method(path,keys)
|
||||
|
||||
|
||||
def scrobble(self,rawscrobble,client=None):
|
||||
|
||||
# fixing etc is handled by the main scrobble function
|
||||
try:
|
||||
return database.incoming_scrobble(rawscrobble,api=self.__apiname__,client=client)
|
||||
except Exception:
|
||||
raise ScrobblingException()
|
|
@ -0,0 +1,6 @@
|
|||
class BadAuthException(Exception): pass
|
||||
class InvalidAuthException(Exception): pass
|
||||
class InvalidMethodException(Exception): pass
|
||||
class InvalidSessionKey(Exception): pass
|
||||
class MalformedJSONException(Exception): pass
|
||||
class ScrobblingException(Exception): pass
|
|
@ -0,0 +1,101 @@
|
|||
from ._base import APIHandler
|
||||
from ._exceptions import *
|
||||
from .. import database
|
||||
from ._apikeys import apikeystore
|
||||
|
||||
class Audioscrobbler(APIHandler):
|
||||
__apiname__ = "Audioscrobbler"
|
||||
__doclink__ = "https://www.last.fm/api/scrobbling"
|
||||
__aliases__ = [
|
||||
"audioscrobbler/2.0",
|
||||
"gnufm/2.0",
|
||||
"gnukebox/2.0",
|
||||
]
|
||||
|
||||
def init(self):
|
||||
|
||||
# no need to save these on disk, clients can always request a new session
|
||||
self.mobile_sessions = {}
|
||||
self.methods = {
|
||||
"auth.getMobileSession":self.authmobile,
|
||||
"track.scrobble":self.submit_scrobble
|
||||
}
|
||||
self.errors = {
|
||||
BadAuthException:(400,{"error":6,"message":"Requires authentication"}),
|
||||
InvalidAuthException:(401,{"error":4,"message":"Invalid credentials"}),
|
||||
InvalidMethodException:(200,{"error":3,"message":"Invalid method"}),
|
||||
InvalidSessionKey:(403,{"error":9,"message":"Invalid session key"}),
|
||||
ScrobblingException:(500,{"error":8,"message":"Operation failed"})
|
||||
}
|
||||
|
||||
def get_method(self,pathnodes,keys):
|
||||
return keys.get("method")
|
||||
|
||||
def generate_key(self,client):
|
||||
key = "".join(
|
||||
str(
|
||||
random.choice(
|
||||
list(range(10)) + list("abcdefghijklmnopqrstuvwxyz") +
|
||||
list("ABCDEFGHIJKLMNOPQRSTUVWXYZ"))) for _ in range(64))
|
||||
|
||||
self.mobile_sessions[key] = client
|
||||
return key
|
||||
|
||||
def authmobile(self,pathnodes,keys):
|
||||
token = keys.get("authToken")
|
||||
user = keys.get("username")
|
||||
password = keys.get("password")
|
||||
# either username and password
|
||||
if user is not None and password is not None:
|
||||
client = apikeystore.check_and_identify_key(password)
|
||||
if client:
|
||||
sessionkey = self.generate_key(client)
|
||||
return 200,{"session":{"key":sessionkey}}
|
||||
else:
|
||||
raise InvalidAuthException()
|
||||
# or username and token (deprecated by lastfm)
|
||||
elif user is not None and token is not None:
|
||||
for client in apikeystore:
|
||||
key = apikeystore[client]
|
||||
if md5(user + md5(key)) == token:
|
||||
sessionkey = self.generate_key(client)
|
||||
return 200,{"session":{"key":sessionkey}}
|
||||
raise InvalidAuthException()
|
||||
else:
|
||||
raise BadAuthException()
|
||||
|
||||
def submit_scrobble(self,pathnodes,keys):
|
||||
key = keys.get("sk")
|
||||
if key is None:
|
||||
raise InvalidSessionKey()
|
||||
client = self.mobile_sessions.get(key)
|
||||
if not client:
|
||||
raise InvalidSessionKey()
|
||||
if "track" in keys and "artist" in keys:
|
||||
artiststr,titlestr = keys["artist"], keys["track"]
|
||||
#(artists,title) = cla.fullclean(artiststr,titlestr)
|
||||
try:
|
||||
timestamp = int(keys["timestamp"])
|
||||
except Exception:
|
||||
timestamp = None
|
||||
#database.createScrobble(artists,title,timestamp)
|
||||
self.scrobble({'track_artists':[artiststr],'track_title':titlestr,'scrobble_time':timestamp},client=client)
|
||||
else:
|
||||
for num in range(50):
|
||||
if "track[" + str(num) + "]" in keys:
|
||||
artiststr,titlestr = keys["artist[" + str(num) + "]"], keys["track[" + str(num) + "]"]
|
||||
#(artists,title) = cla.fullclean(artiststr,titlestr)
|
||||
timestamp = int(keys["timestamp[" + str(num) + "]"])
|
||||
#database.createScrobble(artists,title,timestamp)
|
||||
self.scrobble(artiststr,titlestr,time=timestamp)
|
||||
|
||||
return 200,{"scrobbles":{"@attr":{"ignored":0}}}
|
||||
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
|
||||
def md5(input):
|
||||
m = hashlib.md5()
|
||||
m.update(bytes(input,encoding="utf-8"))
|
||||
return m.hexdigest()
|
|
@ -0,0 +1,124 @@
|
|||
from ._base import APIHandler
|
||||
from ._exceptions import *
|
||||
from .. import database
|
||||
from ._apikeys import apikeystore
|
||||
|
||||
from bottle import request
|
||||
|
||||
class AudioscrobblerLegacy(APIHandler):
|
||||
__apiname__ = "Legacy Audioscrobbler"
|
||||
__doclink__ = "https://web.archive.org/web/20190531021725/https://www.last.fm/api/submissions"
|
||||
__aliases__ = [
|
||||
"audioscrobbler_legacy",
|
||||
"audioscrobbler/1.2"
|
||||
]
|
||||
|
||||
def init(self):
|
||||
|
||||
# no need to save these on disk, clients can always request a new session
|
||||
self.mobile_sessions = {}
|
||||
self.methods = {
|
||||
"handshake":self.handshake,
|
||||
"nowplaying":self.now_playing,
|
||||
"scrobble":self.submit_scrobble
|
||||
}
|
||||
self.errors = {
|
||||
BadAuthException:(403,"BADAUTH\n"),
|
||||
InvalidAuthException:(403,"BADAUTH\n"),
|
||||
InvalidMethodException:(400,"FAILED\n"),
|
||||
InvalidSessionKey:(403,"BADSESSION\n"),
|
||||
ScrobblingException:(500,"FAILED\n")
|
||||
}
|
||||
|
||||
def get_method(self,pathnodes,keys):
|
||||
if keys.get("hs") == 'true': return 'handshake'
|
||||
else: return pathnodes[0]
|
||||
|
||||
def handshake(self,pathnodes,keys):
|
||||
auth = keys.get("a")
|
||||
timestamp = keys.get("t")
|
||||
apikey = keys.get("api_key")
|
||||
host = keys.get("Host")
|
||||
protocol = 'http' if (keys.get("u") == 'nossl') else request.urlparts.scheme
|
||||
|
||||
if auth is not None:
|
||||
for client in apikeystore:
|
||||
key = apikeystore[client]
|
||||
if self.check_token(auth,key,timestamp):
|
||||
sessionkey = self.generate_key(client)
|
||||
return 200, (
|
||||
"OK\n"
|
||||
f"{sessionkey}\n"
|
||||
f"{protocol}://{host}/apis/audioscrobbler_legacy/nowplaying\n"
|
||||
f"{protocol}://{host}/apis/audioscrobbler_legacy/scrobble\n"
|
||||
)
|
||||
else:
|
||||
raise InvalidAuthException()
|
||||
else:
|
||||
raise BadAuthException()
|
||||
|
||||
def now_playing(self,pathnodes,keys):
|
||||
# I see no implementation in the other compatible APIs, so I have just
|
||||
# created a route that always says it was successful except if the
|
||||
# session is invalid
|
||||
if keys.get("s") is None or keys.get("s") not in self.mobile_sessions:
|
||||
raise InvalidSessionKey()
|
||||
else:
|
||||
return 200,"OK\n"
|
||||
|
||||
def submit_scrobble(self,pathnodes,keys):
|
||||
key = keys.get("s")
|
||||
if key is None or key not in self.mobile_sessions:
|
||||
raise InvalidSessionKey()
|
||||
client = self.mobile_sessions.get(key)
|
||||
for count in range(50):
|
||||
artist_key = f"a[{count}]"
|
||||
album_key = f"b[{count}]"
|
||||
length_key = f"l[{count}]"
|
||||
track_key = f"t[{count}]"
|
||||
time_key = f"i[{count}]"
|
||||
if artist_key not in keys or track_key not in keys:
|
||||
return 200,"OK\n"
|
||||
artiststr,titlestr = keys[artist_key], keys[track_key]
|
||||
try:
|
||||
timestamp = int(keys[time_key])
|
||||
except Exception:
|
||||
timestamp = None
|
||||
|
||||
scrobble = {
|
||||
'track_artists':[artiststr],
|
||||
'track_title':titlestr,
|
||||
'scrobble_time':timestamp,
|
||||
}
|
||||
if album_key in keys:
|
||||
scrobble['album_name'] = keys[album_key]
|
||||
if length_key in keys:
|
||||
scrobble['track_length'] = keys[length_key]
|
||||
|
||||
#database.createScrobble(artists,title,timestamp)
|
||||
self.scrobble(scrobble, client=client)
|
||||
return 200,"OK\n"
|
||||
|
||||
|
||||
def check_token(self, received_token, expected_key, ts):
|
||||
expected_token = md5(md5(expected_key) + ts)
|
||||
return received_token == expected_token
|
||||
|
||||
def generate_key(self,client):
|
||||
key = "".join(
|
||||
str(
|
||||
random.choice(
|
||||
list(range(10)) + list("abcdefghijklmnopqrstuvwxyz") +
|
||||
list("ABCDEFGHIJKLMNOPQRSTUVWXYZ"))) for _ in range(64))
|
||||
|
||||
self.mobile_sessions[key] = client
|
||||
return key
|
||||
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
|
||||
def md5(input):
|
||||
m = hashlib.md5()
|
||||
m.update(bytes(input,encoding="utf-8"))
|
||||
return m.hexdigest()
|
|
@ -0,0 +1,106 @@
|
|||
from ._base import APIHandler
|
||||
from ._exceptions import *
|
||||
from .. import database
|
||||
import datetime
|
||||
from ._apikeys import apikeystore
|
||||
|
||||
from ..pkg_global.conf import malojaconfig
|
||||
|
||||
|
||||
class Listenbrainz(APIHandler):
|
||||
__apiname__ = "Listenbrainz"
|
||||
__doclink__ = "https://listenbrainz.readthedocs.io/en/production/"
|
||||
__aliases__ = [
|
||||
"listenbrainz/1",
|
||||
"lbrnz/1"
|
||||
]
|
||||
|
||||
def init(self):
|
||||
self.methods = {
|
||||
"submit-listens":self.submit,
|
||||
"validate-token":self.validate_token
|
||||
}
|
||||
self.errors = {
|
||||
BadAuthException:(401,{"code":401,"error":"You need to provide an Authorization header."}),
|
||||
InvalidAuthException:(401,{"code":401,"error":"Incorrect Authorization"}),
|
||||
InvalidMethodException:(200,{"code":200,"error":"Invalid Method"}),
|
||||
MalformedJSONException:(400,{"code":400,"error":"Invalid JSON document submitted."}),
|
||||
ScrobblingException:(500,{"code":500,"error":"Unspecified server error."})
|
||||
}
|
||||
|
||||
def get_method(self,pathnodes,keys):
|
||||
return pathnodes.pop(0)
|
||||
|
||||
def submit(self,pathnodes,keys):
|
||||
try:
|
||||
token = self.get_token_from_request_keys(keys)
|
||||
except Exception:
|
||||
raise BadAuthException()
|
||||
|
||||
client = apikeystore.check_and_identify_key(token)
|
||||
|
||||
if not client:
|
||||
raise InvalidAuthException()
|
||||
|
||||
try:
|
||||
listentype = keys["listen_type"]
|
||||
payload = keys["payload"]
|
||||
except Exception:
|
||||
raise MalformedJSONException()
|
||||
|
||||
if listentype == "playing_now":
|
||||
return 200,{"status":"ok"}
|
||||
elif listentype in ["single","import"]:
|
||||
for listen in payload:
|
||||
try:
|
||||
metadata = listen["track_metadata"]
|
||||
artiststr, titlestr = metadata["artist_name"], metadata["track_name"]
|
||||
albumstr = metadata.get("release_name")
|
||||
additional = metadata.get("additional_info",{})
|
||||
try:
|
||||
timestamp = int(listen["listened_at"])
|
||||
except Exception:
|
||||
timestamp = None
|
||||
except Exception:
|
||||
raise MalformedJSONException()
|
||||
|
||||
extrafields = {
|
||||
# fields that will not be consumed by regular scrobbling
|
||||
# will go into 'extra'
|
||||
k:additional[k]
|
||||
for k in ['track_mbid', 'release_mbid', 'artist_mbids','recording_mbid','tags']
|
||||
if k in additional
|
||||
}
|
||||
|
||||
self.scrobble({
|
||||
'track_artists':[artiststr],
|
||||
'track_title':titlestr,
|
||||
'album_name':albumstr,
|
||||
'scrobble_time':timestamp,
|
||||
'track_length': additional.get("duration"),
|
||||
**extrafields
|
||||
},client=client)
|
||||
|
||||
return 200,{"status":"ok"}
|
||||
|
||||
|
||||
def validate_token(self,pathnodes,keys):
|
||||
try:
|
||||
token = self.get_token_from_request_keys(keys)
|
||||
except Exception:
|
||||
raise BadAuthException()
|
||||
if not apikeystore.check_key(token):
|
||||
raise InvalidAuthException()
|
||||
else:
|
||||
return 200,{"code":200,"message":"Token valid.","valid":True,"user_name":malojaconfig["NAME"]}
|
||||
|
||||
def get_token_from_request_keys(self,keys):
|
||||
if 'token' in keys:
|
||||
return keys.get("token").strip()
|
||||
if 'Authorization' in keys:
|
||||
auth = keys.get("Authorization")
|
||||
if auth.startswith('token '):
|
||||
return auth.replace("token ","",1).strip()
|
||||
if auth.startswith('Token '):
|
||||
return auth.replace("Token ","",1).strip()
|
||||
raise BadAuthException()
|
|
@ -0,0 +1,748 @@
|
|||
import os
|
||||
import math
|
||||
import traceback
|
||||
|
||||
from bottle import response, static_file, request, FormsDict
|
||||
|
||||
from doreah.logging import log
|
||||
from doreah.auth import authenticated_api, authenticated_api_with_alternate, authenticated_function
|
||||
|
||||
# nimrodel API
|
||||
from nimrodel import EAPI as API
|
||||
from nimrodel import Multi
|
||||
|
||||
|
||||
from .. import database
|
||||
from ..pkg_global.conf import malojaconfig, data_dir
|
||||
|
||||
|
||||
|
||||
from ..__pkginfo__ import VERSION
|
||||
from ..malojauri import uri_to_internal, compose_querystring, internal_to_uri
|
||||
from .. import images
|
||||
from ._apikeys import apikeystore, api_key_correct
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
api = API(delay=True)
|
||||
api.__apipath__ = "mlj_1"
|
||||
|
||||
|
||||
|
||||
|
||||
errors = {
|
||||
database.exceptions.MissingScrobbleParameters: lambda e: (400,{
|
||||
"status":"failure",
|
||||
"error":{
|
||||
'type':'missing_scrobble_data',
|
||||
'value':e.params,
|
||||
'desc':"The scrobble is missing needed parameters."
|
||||
}
|
||||
}),
|
||||
database.exceptions.MissingEntityParameter: lambda e: (400,{
|
||||
"status":"error",
|
||||
"error":{
|
||||
'type':'missing_entity_parameter',
|
||||
'value':None,
|
||||
'desc':"This API call is not valid without an entity (track or artist)."
|
||||
}
|
||||
}),
|
||||
database.exceptions.EntityExists: lambda e: (409,{
|
||||
"status":"failure",
|
||||
"error":{
|
||||
'type':'entity_exists',
|
||||
'value':e.entitydict,
|
||||
'desc':"This entity already exists in the database. Consider merging instead."
|
||||
}
|
||||
}),
|
||||
database.exceptions.DatabaseNotBuilt: lambda e: (503,{
|
||||
"status":"error",
|
||||
"error":{
|
||||
'type':'server_not_ready',
|
||||
'value':'db_upgrade',
|
||||
'desc':"The database is being upgraded. Please try again later."
|
||||
}
|
||||
}),
|
||||
images.MalformedB64: lambda e: (400,{
|
||||
"status":"failure",
|
||||
"error":{
|
||||
'type':'malformed_b64',
|
||||
'value':None,
|
||||
'desc':"The provided base 64 string is not valid."
|
||||
}
|
||||
}),
|
||||
# for http errors, use their status code
|
||||
Exception: lambda e: ((e.status_code if hasattr(e,'statuscode') else 500),{
|
||||
"status":"failure",
|
||||
"error":{
|
||||
'type':'unknown_error',
|
||||
'value':e.__repr__(),
|
||||
'desc':"The server has encountered an exception."
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
def catch_exceptions(func):
|
||||
def protector(*args,**kwargs):
|
||||
try:
|
||||
return func(*args,**kwargs)
|
||||
except Exception as e:
|
||||
print(traceback.format_exc())
|
||||
for etype in errors:
|
||||
if isinstance(e,etype):
|
||||
errorhandling = errors[etype](e)
|
||||
response.status = errorhandling[0]
|
||||
return errorhandling[1]
|
||||
|
||||
protector.__doc__ = func.__doc__
|
||||
protector.__annotations__ = func.__annotations__
|
||||
return protector
|
||||
|
||||
|
||||
def add_common_args_to_docstring(filterkeys=False,limitkeys=False,delimitkeys=False,amountkeys=False):
|
||||
def decorator(func):
|
||||
timeformats = "Possible formats include '2022', '2022/08', '2022/08/01', '2022/W42', 'today', 'thismonth', 'monday', 'august'"
|
||||
|
||||
if filterkeys:
|
||||
func.__doc__ += f"""
|
||||
:param string title: Track title
|
||||
:param string artist: Track artist. Can be specified multiple times.
|
||||
:param bool associated: Whether to include associated artists.
|
||||
"""
|
||||
if limitkeys:
|
||||
func.__doc__ += f"""
|
||||
:param string from: Start of the desired time range. Can also be called since or start. {timeformats}
|
||||
:param string until: End of the desired range. Can also be called to or end. {timeformats}
|
||||
:param string in: Desired range. Can also be called within or during. {timeformats}
|
||||
"""
|
||||
if delimitkeys:
|
||||
func.__doc__ += """
|
||||
:param string step: Step, e.g. month or week.
|
||||
:param int stepn: Number of base type units per step
|
||||
:param int trail: How many preceding steps should be factored in.
|
||||
:param bool cumulative: Instead of a fixed trail length, use all history up to this point.
|
||||
"""
|
||||
if amountkeys:
|
||||
func.__doc__ += """
|
||||
:param int page: Page to show
|
||||
:param int perpage: Entries per page.
|
||||
:param int max: Legacy. Show first page with this many entries.
|
||||
"""
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
|
||||
@api.get("test")
|
||||
@catch_exceptions
|
||||
def test_server(key=None):
|
||||
"""Pings the server. If an API key is supplied, the server will respond with 200
|
||||
if the key is correct and 403 if it isn't. If no key is supplied, the server will
|
||||
always respond with 200.
|
||||
|
||||
:param string key: An API key to be tested. Optional.
|
||||
:return: status (String), error (String)
|
||||
:rtype: Dictionary
|
||||
"""
|
||||
response.set_header("Access-Control-Allow-Origin","*")
|
||||
if key is not None and not apikeystore.check_key(key):
|
||||
response.status = 403
|
||||
return {
|
||||
"status":"error",
|
||||
"error":"Wrong API key"
|
||||
}
|
||||
|
||||
else:
|
||||
response.status = 200
|
||||
return {
|
||||
"status":"ok"
|
||||
}
|
||||
|
||||
|
||||
@api.get("serverinfo")
|
||||
@catch_exceptions
|
||||
def server_info():
|
||||
"""Returns basic information about the server.
|
||||
|
||||
:return: name (String), version (Tuple), versionstring (String), db_status (Mapping). Additional keys can be added at any point, but will not be removed within API version.
|
||||
:rtype: Dictionary
|
||||
"""
|
||||
|
||||
|
||||
response.set_header("Access-Control-Allow-Origin","*")
|
||||
|
||||
return {
|
||||
"name":malojaconfig["NAME"],
|
||||
"version":VERSION.split("."),
|
||||
"versionstring":VERSION,
|
||||
"db_status":database.dbstatus
|
||||
}
|
||||
|
||||
|
||||
## API ENDPOINTS THAT CLOSELY MATCH ONE DATABASE FUNCTION
|
||||
|
||||
|
||||
@api.get("scrobbles")
|
||||
@catch_exceptions
|
||||
@add_common_args_to_docstring(filterkeys=True,limitkeys=True,amountkeys=True)
|
||||
def get_scrobbles_external(**keys):
|
||||
"""Returns a list of scrobbles.
|
||||
|
||||
:return: list (List)
|
||||
:rtype: Dictionary
|
||||
"""
|
||||
k_filter, k_time, _, k_amount, _ = uri_to_internal(keys,api=True)
|
||||
ckeys = {**k_filter, **k_time, **k_amount}
|
||||
|
||||
result = database.get_scrobbles(**ckeys)
|
||||
|
||||
offset = (k_amount.get('page') * k_amount.get('perpage')) if k_amount.get('perpage') is not math.inf else 0
|
||||
result = result[offset:]
|
||||
if k_amount.get('perpage') is not math.inf: result = result[:k_amount.get('perpage')]
|
||||
|
||||
return {
|
||||
"status":"ok",
|
||||
"list":result
|
||||
}
|
||||
|
||||
|
||||
@api.get("numscrobbles")
|
||||
@catch_exceptions
|
||||
@add_common_args_to_docstring(filterkeys=True,limitkeys=True,amountkeys=True)
|
||||
def get_scrobbles_num_external(**keys):
|
||||
"""Returns amount of scrobbles.
|
||||
|
||||
:return: amount (Integer)
|
||||
:rtype: Dictionary
|
||||
"""
|
||||
k_filter, k_time, _, k_amount, _ = uri_to_internal(keys)
|
||||
ckeys = {**k_filter, **k_time, **k_amount}
|
||||
|
||||
result = database.get_scrobbles_num(**ckeys)
|
||||
|
||||
return {
|
||||
"status":"ok",
|
||||
"amount":result
|
||||
}
|
||||
|
||||
|
||||
|
||||
@api.get("tracks")
|
||||
@catch_exceptions
|
||||
@add_common_args_to_docstring(filterkeys=True)
|
||||
def get_tracks_external(**keys):
|
||||
"""Returns all tracks (optionally of an artist).
|
||||
|
||||
:return: list (List)
|
||||
:rtype: Dictionary
|
||||
"""
|
||||
k_filter, _, _, _, _ = uri_to_internal(keys,forceArtist=True)
|
||||
ckeys = {**k_filter}
|
||||
|
||||
result = database.get_tracks(**ckeys)
|
||||
|
||||
return {
|
||||
"status":"ok",
|
||||
"list":result
|
||||
}
|
||||
|
||||
|
||||
|
||||
@api.get("artists")
|
||||
@catch_exceptions
|
||||
@add_common_args_to_docstring()
|
||||
def get_artists_external():
|
||||
"""Returns all artists.
|
||||
|
||||
:return: list (List)
|
||||
:rtype: Dictionary"""
|
||||
result = database.get_artists()
|
||||
|
||||
return {
|
||||
"status":"ok",
|
||||
"list":result
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@api.get("charts/artists")
|
||||
@catch_exceptions
|
||||
@add_common_args_to_docstring(limitkeys=True)
|
||||
def get_charts_artists_external(**keys):
|
||||
"""Returns artist charts
|
||||
|
||||
:return: list (List)
|
||||
:rtype: Dictionary"""
|
||||
_, k_time, _, _, _ = uri_to_internal(keys)
|
||||
ckeys = {**k_time}
|
||||
|
||||
result = database.get_charts_artists(**ckeys)
|
||||
|
||||
return {
|
||||
"status":"ok",
|
||||
"list":result
|
||||
}
|
||||
|
||||
|
||||
|
||||
@api.get("charts/tracks")
|
||||
@catch_exceptions
|
||||
@add_common_args_to_docstring(filterkeys=True,limitkeys=True)
|
||||
def get_charts_tracks_external(**keys):
|
||||
"""Returns track charts
|
||||
|
||||
:return: list (List)
|
||||
:rtype: Dictionary"""
|
||||
k_filter, k_time, _, _, _ = uri_to_internal(keys,forceArtist=True)
|
||||
ckeys = {**k_filter, **k_time}
|
||||
|
||||
result = database.get_charts_tracks(**ckeys)
|
||||
|
||||
return {
|
||||
"status":"ok",
|
||||
"list":result
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@api.get("pulse")
|
||||
@catch_exceptions
|
||||
@add_common_args_to_docstring(filterkeys=True,limitkeys=True,delimitkeys=True,amountkeys=True)
|
||||
def get_pulse_external(**keys):
|
||||
"""Returns amounts of scrobbles in specified time frames
|
||||
|
||||
:return: list (List)
|
||||
:rtype: Dictionary"""
|
||||
k_filter, k_time, k_internal, k_amount, _ = uri_to_internal(keys)
|
||||
ckeys = {**k_filter, **k_time, **k_internal, **k_amount}
|
||||
|
||||
results = database.get_pulse(**ckeys)
|
||||
|
||||
return {
|
||||
"status":"ok",
|
||||
"list":results
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@api.get("performance")
|
||||
@catch_exceptions
|
||||
@add_common_args_to_docstring(filterkeys=True,limitkeys=True,delimitkeys=True,amountkeys=True)
|
||||
def get_performance_external(**keys):
|
||||
"""Returns artist's or track's rank in specified time frames
|
||||
|
||||
:return: list (List)
|
||||
:rtype: Dictionary"""
|
||||
k_filter, k_time, k_internal, k_amount, _ = uri_to_internal(keys)
|
||||
ckeys = {**k_filter, **k_time, **k_internal, **k_amount}
|
||||
|
||||
results = database.get_performance(**ckeys)
|
||||
|
||||
return {
|
||||
"status":"ok",
|
||||
"list":results
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@api.get("top/artists")
|
||||
@catch_exceptions
|
||||
@add_common_args_to_docstring(limitkeys=True,delimitkeys=True)
|
||||
def get_top_artists_external(**keys):
|
||||
"""Returns respective number 1 artists in specified time frames
|
||||
|
||||
:return: list (List)
|
||||
:rtype: Dictionary"""
|
||||
_, k_time, k_internal, _, _ = uri_to_internal(keys)
|
||||
ckeys = {**k_time, **k_internal}
|
||||
|
||||
results = database.get_top_artists(**ckeys)
|
||||
|
||||
return {
|
||||
"status":"ok",
|
||||
"list":results
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@api.get("top/tracks")
|
||||
@catch_exceptions
|
||||
@add_common_args_to_docstring(limitkeys=True,delimitkeys=True)
|
||||
def get_top_tracks_external(**keys):
|
||||
"""Returns respective number 1 tracks in specified time frames
|
||||
|
||||
:return: list (List)
|
||||
:rtype: Dictionary"""
|
||||
_, k_time, k_internal, _, _ = uri_to_internal(keys)
|
||||
ckeys = {**k_time, **k_internal}
|
||||
|
||||
# IMPLEMENT THIS FOR TOP TRACKS OF ARTIST AS WELL?
|
||||
|
||||
results = database.get_top_tracks(**ckeys)
|
||||
|
||||
return {
|
||||
"status":"ok",
|
||||
"list":results
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@api.get("artistinfo")
|
||||
@catch_exceptions
|
||||
@add_common_args_to_docstring(filterkeys=True)
|
||||
def artist_info_external(**keys):
|
||||
"""Returns information about an artist
|
||||
|
||||
:return: artist (String), scrobbles (Integer), position (Integer), associated (List), medals (Mapping), topweeks (Integer)
|
||||
:rtype: Dictionary"""
|
||||
k_filter, _, _, _, _ = uri_to_internal(keys,forceArtist=True)
|
||||
ckeys = {**k_filter}
|
||||
|
||||
return database.artist_info(**ckeys)
|
||||
|
||||
|
||||
|
||||
@api.get("trackinfo")
|
||||
@catch_exceptions
|
||||
@add_common_args_to_docstring(filterkeys=True)
|
||||
def track_info_external(artist:Multi[str]=[],**keys):
|
||||
"""Returns information about a track
|
||||
|
||||
:return: track (Mapping), scrobbles (Integer), position (Integer), medals (Mapping), certification (String), topweeks (Integer)
|
||||
:rtype: Dictionary"""
|
||||
# transform into a multidict so we can use our nomral uri_to_internal function
|
||||
keys = FormsDict(keys)
|
||||
for a in artist:
|
||||
keys.append("artist",a)
|
||||
k_filter, _, _, _, _ = uri_to_internal(keys,forceTrack=True)
|
||||
ckeys = {**k_filter}
|
||||
|
||||
return database.track_info(**ckeys)
|
||||
|
||||
|
||||
@api.post("newscrobble")
|
||||
@authenticated_function(alternate=api_key_correct,api=True,pass_auth_result_as='auth_result')
|
||||
@catch_exceptions
|
||||
def post_scrobble(
|
||||
artist:Multi=None,
|
||||
artists:list=[],
|
||||
title:str="",
|
||||
album:str=None,
|
||||
albumartists:list=[],
|
||||
duration:int=None,
|
||||
length:int=None,
|
||||
time:int=None,
|
||||
nofix=None,
|
||||
auth_result=None,
|
||||
**extra_kwargs):
|
||||
"""Submit a new scrobble.
|
||||
|
||||
:param string artist: Artist. Can be submitted multiple times as query argument for multiple artists.
|
||||
:param list artists: List of artists.
|
||||
:param string title: Title of the track.
|
||||
:param string album: Name of the album. Optional.
|
||||
:param list albumartists: Album artists. Optional.
|
||||
:param int duration: Actual listened duration of the scrobble in seconds. Optional.
|
||||
:param int length: Total length of the track in seconds. Optional.
|
||||
:param int time: UNIX timestamp of the scrobble. Optional, not needed if scrobble is at time of request.
|
||||
:param flag nofix: Skip server-side metadata parsing. Optional.
|
||||
|
||||
:return: status (String), track (Mapping)
|
||||
:rtype: Dictionary
|
||||
"""
|
||||
|
||||
rawscrobble = {
|
||||
'track_artists':(artist or []) + artists,
|
||||
'track_title':title,
|
||||
'album_name':album,
|
||||
'album_artists':albumartists,
|
||||
'scrobble_duration':duration,
|
||||
'track_length':length,
|
||||
'scrobble_time':time
|
||||
}
|
||||
|
||||
# for logging purposes, don't pass values that we didn't actually supply
|
||||
rawscrobble = {k:rawscrobble[k] for k in rawscrobble if rawscrobble[k]}
|
||||
|
||||
|
||||
result = database.incoming_scrobble(
|
||||
rawscrobble,
|
||||
client='browser' if auth_result.get('doreah_native_auth_check') else auth_result.get('client'),
|
||||
api='native/v1',
|
||||
fix=(nofix is None)
|
||||
)
|
||||
|
||||
responsedict = {
|
||||
'status': 'success',
|
||||
'track': {
|
||||
'artists':result['track']['artists'],
|
||||
'title':result['track']['title']
|
||||
},
|
||||
'desc':f"Scrobbled {result['track']['title']} by {', '.join(result['track']['artists'])}"
|
||||
}
|
||||
if extra_kwargs:
|
||||
responsedict['warnings'] = [
|
||||
{'type':'invalid_keyword_ignored','value':k,
|
||||
'desc':"This key was not recognized by the server and has been discarded."}
|
||||
for k in extra_kwargs
|
||||
]
|
||||
if artist and artists:
|
||||
responsedict['warnings'] = [
|
||||
{'type':'mixed_schema','value':['artist','artists'],
|
||||
'desc':"These two fields are meant as alternative methods to submit information. Use of both is discouraged, but works at the moment."}
|
||||
]
|
||||
return responsedict
|
||||
|
||||
|
||||
|
||||
|
||||
@api.post("addpicture")
|
||||
@authenticated_function(alternate=api_key_correct,api=True)
|
||||
@catch_exceptions
|
||||
def add_picture(b64,artist:Multi=[],title=None):
|
||||
"""Uploads a new image for an artist or track.
|
||||
|
||||
param string b64: Base 64 representation of the image
|
||||
param string artist: Artist name. Can be supplied multiple times for tracks with multiple artists.
|
||||
param string title: Title of the track. Optional.
|
||||
|
||||
"""
|
||||
keys = FormsDict()
|
||||
for a in artist:
|
||||
keys.append("artist",a)
|
||||
if title is not None: keys.append("title",title)
|
||||
k_filter, _, _, _, _ = uri_to_internal(keys)
|
||||
if "track" in k_filter: k_filter = k_filter["track"]
|
||||
url = images.set_image(b64,**k_filter)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'url': url
|
||||
}
|
||||
|
||||
|
||||
|
||||
@api.post("importrules")
|
||||
@authenticated_function(api=True)
|
||||
@catch_exceptions
|
||||
def import_rulemodule(**keys):
|
||||
"""Internal Use Only"""
|
||||
filename = keys.get("filename")
|
||||
remove = keys.get("remove") is not None
|
||||
validchars = "-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
filename = "".join(c for c in filename if c in validchars)
|
||||
|
||||
if remove:
|
||||
log("Deactivating predefined rulefile " + filename)
|
||||
os.remove(data_dir['rules'](filename + ".tsv"))
|
||||
else:
|
||||
log("Importing predefined rulefile " + filename)
|
||||
os.symlink(data_dir['rules']("predefined/" + filename + ".tsv"),data_dir['rules'](filename + ".tsv"))
|
||||
|
||||
|
||||
|
||||
@api.post("rebuild")
|
||||
@authenticated_function(api=True)
|
||||
@catch_exceptions
|
||||
def rebuild(**keys):
|
||||
"""Internal Use Only"""
|
||||
log("Database rebuild initiated!")
|
||||
database.sync()
|
||||
dbstatus['rebuildinprogress'] = True
|
||||
from ..proccontrol.tasks.fixexisting import fix
|
||||
fix()
|
||||
global cla
|
||||
cla = CleanerAgent()
|
||||
database.build_db()
|
||||
database.invalidate_caches()
|
||||
|
||||
|
||||
|
||||
|
||||
@api.get("search")
|
||||
@catch_exceptions
|
||||
def search(**keys):
|
||||
"""Internal Use Only"""
|
||||
query = keys.get("query")
|
||||
max_ = keys.get("max")
|
||||
if max_ is not None: max_ = int(max_)
|
||||
query = query.lower()
|
||||
|
||||
artists = database.db_search(query,type="ARTIST")
|
||||
tracks = database.db_search(query,type="TRACK")
|
||||
|
||||
|
||||
|
||||
# if the string begins with the query it's a better match, if a word in it begins with it, still good
|
||||
# also, shorter is better (because longer titles would be easier to further specify)
|
||||
artists.sort(key=lambda x: ((0 if x.lower().startswith(query) else 1 if " " + query in x.lower() else 2),len(x)))
|
||||
tracks.sort(key=lambda x: ((0 if x["title"].lower().startswith(query) else 1 if " " + query in x["title"].lower() else 2),len(x["title"])))
|
||||
|
||||
# add links
|
||||
artists_result = []
|
||||
for a in artists:
|
||||
result = {
|
||||
'artist': a,
|
||||
'link': "/artist?" + compose_querystring(internal_to_uri({"artist": a})),
|
||||
'image': images.get_artist_image(a)
|
||||
}
|
||||
artists_result.append(result)
|
||||
|
||||
tracks_result = []
|
||||
for t in tracks:
|
||||
result = {
|
||||
'track': t,
|
||||
'link': "/track?" + compose_querystring(internal_to_uri({"track":t})),
|
||||
'image': images.get_track_image(t)
|
||||
}
|
||||
tracks_result.append(result)
|
||||
|
||||
return {"artists":artists_result[:max_],"tracks":tracks_result[:max_]}
|
||||
|
||||
|
||||
@api.post("newrule")
|
||||
@authenticated_function(api=True)
|
||||
@catch_exceptions
|
||||
def newrule(**keys):
|
||||
"""Internal Use Only"""
|
||||
pass
|
||||
# TODO after implementing new rule system
|
||||
#tsv.add_entry(data_dir['rules']("webmade.tsv"),[k for k in keys])
|
||||
#addEntry("rules/webmade.tsv",[k for k in keys])
|
||||
|
||||
|
||||
@api.post("settings")
|
||||
@authenticated_function(api=True)
|
||||
@catch_exceptions
|
||||
def set_settings(**keys):
|
||||
"""Internal Use Only"""
|
||||
malojaconfig.update(keys)
|
||||
|
||||
@api.post("apikeys")
|
||||
@authenticated_function(api=True)
|
||||
@catch_exceptions
|
||||
def set_apikeys(**keys):
|
||||
"""Internal Use Only"""
|
||||
apikeystore.update(keys)
|
||||
|
||||
@api.post("import")
|
||||
@authenticated_function(api=True)
|
||||
@catch_exceptions
|
||||
def import_scrobbles(identifier):
|
||||
"""Internal Use Only"""
|
||||
from ..thirdparty import import_scrobbles
|
||||
import_scrobbles(identifier)
|
||||
|
||||
@api.get("backup")
|
||||
@authenticated_function(api=True)
|
||||
@catch_exceptions
|
||||
def get_backup(**keys):
|
||||
"""Internal Use Only"""
|
||||
from ..proccontrol.tasks.backup import backup
|
||||
import tempfile
|
||||
|
||||
tmpfolder = tempfile.gettempdir()
|
||||
archivefile = backup(tmpfolder)
|
||||
|
||||
return static_file(os.path.basename(archivefile),root=tmpfolder)
|
||||
|
||||
@api.get("export")
|
||||
@authenticated_function(api=True)
|
||||
@catch_exceptions
|
||||
def get_export(**keys):
|
||||
"""Internal Use Only"""
|
||||
from ..proccontrol.tasks.export import export
|
||||
import tempfile
|
||||
|
||||
tmpfolder = tempfile.gettempdir()
|
||||
resultfile = export(tmpfolder)
|
||||
|
||||
return static_file(os.path.basename(resultfile),root=tmpfolder)
|
||||
|
||||
|
||||
@api.post("delete_scrobble")
|
||||
@authenticated_function(api=True)
|
||||
@catch_exceptions
|
||||
def delete_scrobble(timestamp):
|
||||
"""Internal Use Only"""
|
||||
result = database.remove_scrobble(timestamp)
|
||||
return {
|
||||
"status":"success",
|
||||
"desc":f"Scrobble was deleted!"
|
||||
}
|
||||
|
||||
|
||||
@api.post("edit_artist")
|
||||
@authenticated_function(api=True)
|
||||
@catch_exceptions
|
||||
def edit_artist(id,name):
|
||||
"""Internal Use Only"""
|
||||
result = database.edit_artist(id,name)
|
||||
return {
|
||||
"status":"success"
|
||||
}
|
||||
|
||||
@api.post("edit_track")
|
||||
@authenticated_function(api=True)
|
||||
@catch_exceptions
|
||||
def edit_track(id,title):
|
||||
"""Internal Use Only"""
|
||||
result = database.edit_track(id,{'title':title})
|
||||
return {
|
||||
"status":"success"
|
||||
}
|
||||
|
||||
|
||||
@api.post("merge_tracks")
|
||||
@authenticated_function(api=True)
|
||||
@catch_exceptions
|
||||
def merge_tracks(target_id,source_ids):
|
||||
"""Internal Use Only"""
|
||||
result = database.merge_tracks(target_id,source_ids)
|
||||
return {
|
||||
"status":"success"
|
||||
}
|
||||
|
||||
@api.post("merge_artists")
|
||||
@authenticated_function(api=True)
|
||||
@catch_exceptions
|
||||
def merge_artists(target_id,source_ids):
|
||||
"""Internal Use Only"""
|
||||
result = database.merge_artists(target_id,source_ids)
|
||||
return {
|
||||
"status":"success"
|
||||
}
|
||||
|
||||
@api.post("reparse_scrobble")
|
||||
@authenticated_function(api=True)
|
||||
@catch_exceptions
|
||||
def reparse_scrobble(timestamp):
|
||||
"""Internal Use Only"""
|
||||
result = database.reparse_scrobble(timestamp)
|
||||
if result:
|
||||
return {
|
||||
"status":"success",
|
||||
"desc":f"Scrobble was reparsed!",
|
||||
"scrobble":result
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status":"no_operation",
|
||||
"desc":"The scrobble was not changed."
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue