├── migrations ├── .gitignore └── Version20240401205417.php ├── tests ├── _output │ └── .gitignore ├── Acceptance.suite.yml ├── Unit.suite.yml ├── Api.suite.yml ├── bootstrap.php ├── Support │ ├── UnitTester.php │ ├── AcceptanceTester.php │ └── ApiTester.php ├── Acceptance │ ├── FrontendCest.php │ └── BackendCest.php └── Unit │ ├── YoutubeVideoTitleParsingTest.php │ └── UtilsTest.php ├── assets ├── styles │ └── app.css ├── scss │ ├── styles.scss │ └── partials │ │ ├── reset.scss │ │ ├── embed.scss │ │ └── iphone-style.scss ├── js │ ├── main.js │ ├── show.js │ ├── about.js │ ├── trends.js │ ├── twig │ │ └── result.html.twig │ └── vendor │ │ └── tablesort.js └── widget │ ├── widget-overlay.scss │ └── widget.scss ├── templates ├── api │ └── api.less ├── macros │ └── tools.twig ├── bundles │ └── TwigBundle │ │ └── Exception │ │ ├── error.html.twig │ │ ├── error404.html.twig │ │ └── error503.html.twig ├── admin │ ├── _base.html.twig │ ├── dashboard.html.twig │ ├── login.html.twig │ ├── clients.html.twig │ └── clients.new.html.twig ├── _widget.html.twig ├── about.html.twig ├── trends.html.twig └── base.html.twig ├── deploy ├── vars.yml.dist ├── Caddyfile ├── nginx.conf └── deploy.yml ├── public ├── favicon.ico ├── favicon.png ├── img │ ├── close.png │ ├── error.png │ ├── logo.png │ ├── reset.png │ ├── share.png │ ├── social.png │ ├── album_on.png │ ├── browsers.png │ ├── coverlay.png │ ├── home_pic.png │ ├── success.png │ ├── track_on.png │ ├── twitter.png │ ├── ajax-loader.gif │ ├── album_link.png │ ├── album_off.png │ ├── artist_link.png │ ├── background.png │ ├── extra_cover.png │ ├── extra_remix.png │ ├── fact_free.png │ ├── fact_picks.png │ ├── fact_widget.png │ ├── icon_search.png │ ├── logo_footer.png │ ├── new_window.png │ ├── song_link.png │ ├── track_off.png │ ├── creator_idea.png │ ├── fact_friends.png │ ├── fact_patterns.png │ ├── fact_sharing.png │ ├── lang_underlay.png │ ├── more_options.png │ ├── nothumb_album.png │ ├── nothumb_track.png │ ├── options_down.png │ ├── plus_options.png │ ├── widget_button.png │ ├── background_menu.png │ ├── creator_design.png │ ├── dashboard │ │ ├── album.png │ │ ├── song.png │ │ ├── users.png │ │ └── song-alt.png │ ├── extra_acoustic.png │ ├── fact_pertinence.png │ ├── icon_search_help.png │ ├── iphone-style │ │ ├── off.png │ │ ├── on.png │ │ ├── slider.png │ │ ├── slider_center.png │ │ ├── slider_left.png │ │ └── slider_right.png │ ├── pagination_left.png │ ├── pagination_right.png │ ├── ajax-loader-widget.gif │ └── platforms │ │ ├── platform_amazon.png │ │ ├── platform_deezer.png │ │ ├── platform_groove.png │ │ ├── platform_hypem.png │ │ ├── platform_itunes.png │ │ ├── platform_lastfm.png │ │ ├── platform_napster.png │ │ ├── platform_qobuz.png │ │ ├── platform_spotify.png │ │ ├── platform_tidal.png │ │ ├── platform_youtube.png │ │ ├── platform_mixcloud.png │ │ ├── platform_chart_amazon.png │ │ ├── platform_chart_deezer.png │ │ ├── platform_chart_groove.png │ │ ├── platform_chart_hypem.png │ │ ├── platform_chart_itunes.png │ │ ├── platform_chart_lastfm.png │ │ ├── platform_chart_qobuz.png │ │ ├── platform_chart_tidal.png │ │ ├── platform_full_amazon.png │ │ ├── platform_full_deezer.png │ │ ├── platform_full_groove.png │ │ ├── platform_full_hypem.png │ │ ├── platform_full_itunes.png │ │ ├── platform_full_lastfm.png │ │ ├── platform_full_napster.png │ │ ├── platform_full_qobuz.png │ │ ├── platform_full_spotify.png │ │ ├── platform_full_tidal.png │ │ ├── platform_full_youtube.png │ │ ├── platform_soundcloud.png │ │ ├── platform_chart_mixcloud.png │ │ ├── platform_chart_napster.png │ │ ├── platform_chart_spotify.png │ │ ├── platform_chart_youtube.png │ │ ├── platform_full_mixcloud.png │ │ ├── platform_chart_soundcloud.png │ │ ├── platform_full_soundcloud.png │ │ └── platform_youtube_compliance.png ├── index.php └── build │ ├── js │ ├── main.js │ ├── show.js │ ├── about.js │ ├── trends.js │ ├── vendor │ │ └── tablesort.js │ ├── widget.js │ └── twig │ │ └── result.html.twig │ └── css │ ├── widget-overlay.css │ ├── embed.css │ └── widget.css ├── config ├── packages │ ├── mailer.yaml │ ├── asset_mapper.yaml │ ├── translation.yaml │ ├── nelmio_cors.yaml │ ├── twig.yaml │ ├── debug.yaml │ ├── doctrine_migrations.yaml │ ├── routing.yaml │ ├── validator.yaml │ ├── web_profiler.yaml │ ├── framework.yaml │ ├── nyholm_psr7.yaml │ ├── cache.yaml │ ├── league_oauth2_server.yaml │ ├── doctrine.yaml │ ├── monolog.yaml │ └── security.yaml ├── routes │ ├── security.yaml │ ├── framework.yaml │ ├── league_oauth2_server.yaml │ └── web_profiler.yaml ├── routes.yaml ├── preload.php ├── bundles.php └── services.yaml ├── src ├── Dataclass │ └── MusicalEntity │ │ ├── MusicalEntityMergeException.php │ │ ├── MusicalEntityInterface.php │ │ └── Entities │ │ └── Album.php ├── Kernel.php ├── Services │ ├── Platforms │ │ ├── Interfaces │ │ │ ├── WebStoreInterface.php │ │ │ ├── ScrobblingPlatformInterface.php │ │ │ ├── WebStreamingPlatformInterface.php │ │ │ └── GeneralPlatformInterface.php │ │ ├── PlatformException.php │ │ └── PlatformResult.php │ └── ClientCredentialsGrant.php ├── Command │ ├── StatsUpdaterCommand.php │ ├── ExpiredIntentsCleanerCommand.php │ └── PlatformIpsFetcherCommand.php ├── Serializer │ └── ApiErrorSerializer.php ├── Repository │ └── ApiClientRepository.php ├── EventSubscriber │ ├── LocaleSubscriber.php │ ├── ApiStatsSubscriber.php │ └── ApiBypassSubscriber.php ├── Controller │ ├── SecurityController.php │ └── BackendController.php ├── Security │ ├── AdminUser.php │ ├── AdminUserProvider.php │ └── LoginFormAuthenticator.php ├── Utils │ ├── ApiUtils.php │ └── Utils.php └── Entity │ ├── ApiClient.php │ └── Item.php ├── .editorconfig ├── .env.test ├── examples ├── search.curl.sh ├── search.requests.py ├── search.request.js ├── search.curl.php └── postman │ └── tuneefy.postman_environment.json ├── bin ├── console └── phpunit ├── codeception.yml ├── .php-cs-fixer.php ├── .gitignore ├── package.json ├── phpunit.xml.dist ├── .env ├── gulpfile.js ├── README.md └── composer.json /migrations/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/_output/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /assets/styles/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: skyblue; 3 | } 4 | -------------------------------------------------------------------------------- /templates/api/api.less: -------------------------------------------------------------------------------- 1 | .action dl.inner { 2 | margin-top: 1px; 3 | } -------------------------------------------------------------------------------- /deploy/vars.yml.dist: -------------------------------------------------------------------------------- 1 | tuneefy_hosts: frontend 2 | project_path: /var/www/tuneefy -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/favicon.png -------------------------------------------------------------------------------- /public/img/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/close.png -------------------------------------------------------------------------------- /public/img/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/error.png -------------------------------------------------------------------------------- /public/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/logo.png -------------------------------------------------------------------------------- /public/img/reset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/reset.png -------------------------------------------------------------------------------- /public/img/share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/share.png -------------------------------------------------------------------------------- /public/img/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/social.png -------------------------------------------------------------------------------- /config/packages/mailer.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | mailer: 3 | dsn: '%env(MAILER_DSN)%' 4 | -------------------------------------------------------------------------------- /public/img/album_on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/album_on.png -------------------------------------------------------------------------------- /public/img/browsers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/browsers.png -------------------------------------------------------------------------------- /public/img/coverlay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/coverlay.png -------------------------------------------------------------------------------- /public/img/home_pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/home_pic.png -------------------------------------------------------------------------------- /public/img/success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/success.png -------------------------------------------------------------------------------- /public/img/track_on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/track_on.png -------------------------------------------------------------------------------- /public/img/twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/twitter.png -------------------------------------------------------------------------------- /public/img/ajax-loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/ajax-loader.gif -------------------------------------------------------------------------------- /public/img/album_link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/album_link.png -------------------------------------------------------------------------------- /public/img/album_off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/album_off.png -------------------------------------------------------------------------------- /public/img/artist_link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/artist_link.png -------------------------------------------------------------------------------- /public/img/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/background.png -------------------------------------------------------------------------------- /public/img/extra_cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/extra_cover.png -------------------------------------------------------------------------------- /public/img/extra_remix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/extra_remix.png -------------------------------------------------------------------------------- /public/img/fact_free.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/fact_free.png -------------------------------------------------------------------------------- /public/img/fact_picks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/fact_picks.png -------------------------------------------------------------------------------- /public/img/fact_widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/fact_widget.png -------------------------------------------------------------------------------- /public/img/icon_search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/icon_search.png -------------------------------------------------------------------------------- /public/img/logo_footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/logo_footer.png -------------------------------------------------------------------------------- /public/img/new_window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/new_window.png -------------------------------------------------------------------------------- /public/img/song_link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/song_link.png -------------------------------------------------------------------------------- /public/img/track_off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/track_off.png -------------------------------------------------------------------------------- /public/img/creator_idea.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/creator_idea.png -------------------------------------------------------------------------------- /public/img/fact_friends.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/fact_friends.png -------------------------------------------------------------------------------- /public/img/fact_patterns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/fact_patterns.png -------------------------------------------------------------------------------- /public/img/fact_sharing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/fact_sharing.png -------------------------------------------------------------------------------- /public/img/lang_underlay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/lang_underlay.png -------------------------------------------------------------------------------- /public/img/more_options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/more_options.png -------------------------------------------------------------------------------- /public/img/nothumb_album.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/nothumb_album.png -------------------------------------------------------------------------------- /public/img/nothumb_track.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/nothumb_track.png -------------------------------------------------------------------------------- /public/img/options_down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/options_down.png -------------------------------------------------------------------------------- /public/img/plus_options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/plus_options.png -------------------------------------------------------------------------------- /public/img/widget_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/widget_button.png -------------------------------------------------------------------------------- /public/img/background_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/background_menu.png -------------------------------------------------------------------------------- /public/img/creator_design.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/creator_design.png -------------------------------------------------------------------------------- /public/img/dashboard/album.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/dashboard/album.png -------------------------------------------------------------------------------- /public/img/dashboard/song.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/dashboard/song.png -------------------------------------------------------------------------------- /public/img/dashboard/users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/dashboard/users.png -------------------------------------------------------------------------------- /public/img/extra_acoustic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/extra_acoustic.png -------------------------------------------------------------------------------- /public/img/fact_pertinence.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/fact_pertinence.png -------------------------------------------------------------------------------- /public/img/icon_search_help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/icon_search_help.png -------------------------------------------------------------------------------- /public/img/iphone-style/off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/iphone-style/off.png -------------------------------------------------------------------------------- /public/img/iphone-style/on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/iphone-style/on.png -------------------------------------------------------------------------------- /public/img/pagination_left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/pagination_left.png -------------------------------------------------------------------------------- /public/img/pagination_right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/pagination_right.png -------------------------------------------------------------------------------- /config/routes/security.yaml: -------------------------------------------------------------------------------- 1 | _security_logout: 2 | resource: security.route_loader.logout 3 | type: service 4 | -------------------------------------------------------------------------------- /public/img/ajax-loader-widget.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/ajax-loader-widget.gif -------------------------------------------------------------------------------- /public/img/dashboard/song-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/dashboard/song-alt.png -------------------------------------------------------------------------------- /public/img/iphone-style/slider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/iphone-style/slider.png -------------------------------------------------------------------------------- /public/img/iphone-style/slider_center.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/iphone-style/slider_center.png -------------------------------------------------------------------------------- /public/img/iphone-style/slider_left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/iphone-style/slider_left.png -------------------------------------------------------------------------------- /public/img/iphone-style/slider_right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/iphone-style/slider_right.png -------------------------------------------------------------------------------- /public/img/platforms/platform_amazon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_amazon.png -------------------------------------------------------------------------------- /public/img/platforms/platform_deezer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_deezer.png -------------------------------------------------------------------------------- /public/img/platforms/platform_groove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_groove.png -------------------------------------------------------------------------------- /public/img/platforms/platform_hypem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_hypem.png -------------------------------------------------------------------------------- /public/img/platforms/platform_itunes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_itunes.png -------------------------------------------------------------------------------- /public/img/platforms/platform_lastfm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_lastfm.png -------------------------------------------------------------------------------- /public/img/platforms/platform_napster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_napster.png -------------------------------------------------------------------------------- /public/img/platforms/platform_qobuz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_qobuz.png -------------------------------------------------------------------------------- /public/img/platforms/platform_spotify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_spotify.png -------------------------------------------------------------------------------- /public/img/platforms/platform_tidal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_tidal.png -------------------------------------------------------------------------------- /public/img/platforms/platform_youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_youtube.png -------------------------------------------------------------------------------- /public/img/platforms/platform_mixcloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_mixcloud.png -------------------------------------------------------------------------------- /public/img/platforms/platform_chart_amazon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_chart_amazon.png -------------------------------------------------------------------------------- /public/img/platforms/platform_chart_deezer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_chart_deezer.png -------------------------------------------------------------------------------- /public/img/platforms/platform_chart_groove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_chart_groove.png -------------------------------------------------------------------------------- /public/img/platforms/platform_chart_hypem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_chart_hypem.png -------------------------------------------------------------------------------- /public/img/platforms/platform_chart_itunes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_chart_itunes.png -------------------------------------------------------------------------------- /public/img/platforms/platform_chart_lastfm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_chart_lastfm.png -------------------------------------------------------------------------------- /public/img/platforms/platform_chart_qobuz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_chart_qobuz.png -------------------------------------------------------------------------------- /public/img/platforms/platform_chart_tidal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_chart_tidal.png -------------------------------------------------------------------------------- /public/img/platforms/platform_full_amazon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_full_amazon.png -------------------------------------------------------------------------------- /public/img/platforms/platform_full_deezer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_full_deezer.png -------------------------------------------------------------------------------- /public/img/platforms/platform_full_groove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_full_groove.png -------------------------------------------------------------------------------- /public/img/platforms/platform_full_hypem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_full_hypem.png -------------------------------------------------------------------------------- /public/img/platforms/platform_full_itunes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_full_itunes.png -------------------------------------------------------------------------------- /public/img/platforms/platform_full_lastfm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_full_lastfm.png -------------------------------------------------------------------------------- /public/img/platforms/platform_full_napster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_full_napster.png -------------------------------------------------------------------------------- /public/img/platforms/platform_full_qobuz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_full_qobuz.png -------------------------------------------------------------------------------- /public/img/platforms/platform_full_spotify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_full_spotify.png -------------------------------------------------------------------------------- /public/img/platforms/platform_full_tidal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_full_tidal.png -------------------------------------------------------------------------------- /public/img/platforms/platform_full_youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_full_youtube.png -------------------------------------------------------------------------------- /public/img/platforms/platform_soundcloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_soundcloud.png -------------------------------------------------------------------------------- /templates/macros/tools.twig: -------------------------------------------------------------------------------- 1 | {% macro ellipsis(txt, len) -%} 2 | {{ txt|length > len ? txt|slice(0, len) ~ '…' : txt }} 3 | {%- endmacro %} -------------------------------------------------------------------------------- /public/img/platforms/platform_chart_mixcloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_chart_mixcloud.png -------------------------------------------------------------------------------- /public/img/platforms/platform_chart_napster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_chart_napster.png -------------------------------------------------------------------------------- /public/img/platforms/platform_chart_spotify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_chart_spotify.png -------------------------------------------------------------------------------- /public/img/platforms/platform_chart_youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_chart_youtube.png -------------------------------------------------------------------------------- /public/img/platforms/platform_full_mixcloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_full_mixcloud.png -------------------------------------------------------------------------------- /config/routes.yaml: -------------------------------------------------------------------------------- 1 | controllers: 2 | resource: 3 | path: ../src/Controller/ 4 | namespace: App\Controller 5 | type: attribute 6 | -------------------------------------------------------------------------------- /public/img/platforms/platform_chart_soundcloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_chart_soundcloud.png -------------------------------------------------------------------------------- /public/img/platforms/platform_full_soundcloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_full_soundcloud.png -------------------------------------------------------------------------------- /public/img/platforms/platform_youtube_compliance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/tuneefy2/HEAD/public/img/platforms/platform_youtube_compliance.png -------------------------------------------------------------------------------- /config/routes/framework.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | _errors: 3 | resource: '@FrameworkBundle/Resources/config/routing/errors.xml' 4 | prefix: /_error 5 | -------------------------------------------------------------------------------- /config/packages/asset_mapper.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | asset_mapper: 3 | # The paths to make available to the asset mapper. 4 | paths: 5 | - assets/ 6 | -------------------------------------------------------------------------------- /tests/Acceptance.suite.yml: -------------------------------------------------------------------------------- 1 | actor: AcceptanceTester 2 | suite_namespace: Tests\Acceptance 3 | modules: 4 | enabled: 5 | - PhpBrowser: 6 | url: http://127.0.0.1:9999/ -------------------------------------------------------------------------------- /src/Dataclass/MusicalEntity/MusicalEntityMergeException.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 11 | } 12 | -------------------------------------------------------------------------------- /config/packages/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | # Enables validator auto-mapping support. 4 | # For instance, basic validation constraints will be inferred from Doctrine's metadata. 5 | #auto_mapping: 6 | # App\Entity\: [] 7 | 8 | when@test: 9 | framework: 10 | validation: 11 | not_compromised_password: false 12 | -------------------------------------------------------------------------------- /src/Services/Platforms/Interfaces/ScrobblingPlatformInterface.php: -------------------------------------------------------------------------------- 1 | getName().' platform did not respond correctly'.($message ? ': '.$message : '').'.'); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /public/build/js/show.js: -------------------------------------------------------------------------------- 1 | function toggleEmbed(){$("#embedHolder").toggle(),$("#embed").toggleClass("open")}function newTweet(t){var e=($(window).width()-575)/2,n=($(window).height()-400)/2;window.open("https://twitter.com/home?status="+t,"twitter","status=1,width=575,height=400,top="+n+",left="+e)}$(document).ready(function(){$("#mainLink, #embedContent").click(function(t){$(t.target).focus(),$(t.target).select()})}); -------------------------------------------------------------------------------- /templates/bundles/TwigBundle/Exception/error.html.twig: -------------------------------------------------------------------------------- 1 | {% set schemaType, page = "WebPage", "500" %} 2 | 3 | {% extends 'base.html.twig' %} 4 | 5 | {% block content %} 6 |
7 |

{{ "error.500.title"|trans }}

8 |

{{ "error.500.description"|trans|raw }}

9 | {{ "error.action"|trans }} 10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /templates/bundles/TwigBundle/Exception/error404.html.twig: -------------------------------------------------------------------------------- 1 | {% set schemaType, page = "WebPage", "404" %} 2 | 3 | {% extends 'base.html.twig' %} 4 | 5 | {% block content %} 6 |
7 |

{{ "error.404.title"|trans }}

8 |

{{ "error.404.description"|trans|raw }}

9 | {{ "error.action"|trans }} 10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /config/packages/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | web_profiler: 3 | toolbar: true 4 | intercept_redirects: false 5 | 6 | framework: 7 | profiler: 8 | only_exceptions: false 9 | collect_serializer_data: true 10 | 11 | when@test: 12 | web_profiler: 13 | toolbar: false 14 | intercept_redirects: false 15 | 16 | framework: 17 | profiler: { collect: false } 18 | -------------------------------------------------------------------------------- /templates/bundles/TwigBundle/Exception/error503.html.twig: -------------------------------------------------------------------------------- 1 | {% set schemaType, page = "WebPage", "503" %} 2 | 3 | {% extends 'base.html.twig' %} 4 | 5 | {% block content %} 6 |
7 |

{{ "error.503.title"|trans }}

8 |

{{ "error.503.description"|trans|raw }}

9 | {{ "error.action.api"|trans }} 10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /assets/js/main.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function () { 2 | $('#lang span').click(function (e) { 3 | var value = 'tuneefyLocale=' + $(e.target).attr('lang') + '; ' 4 | value += 'expires=Sat, 01 Feb 2042 01:20:42 GMT; path=/; domain= ' + $DOMAIN + ';' 5 | document.cookie = value 6 | location.reload() 7 | }) 8 | 9 | $(document).on('click', '.backToTop', function (e) { 10 | e.preventDefault() 11 | $('html,body').animate({ 12 | scrollTop: 0 13 | }, 1500) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | statsService->updateMaterializedViews(); 23 | 24 | return Command::SUCCESS; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /bin/phpunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | = 80000) { 10 | require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit'; 11 | } else { 12 | define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php'); 13 | require PHPUNIT_COMPOSER_INSTALL; 14 | PHPUnit\TextUI\Command::main(); 15 | } 16 | } else { 17 | if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) { 18 | echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n"; 19 | exit(1); 20 | } 21 | 22 | require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php'; 23 | } 24 | -------------------------------------------------------------------------------- /tests/Acceptance/FrontendCest.php: -------------------------------------------------------------------------------- 1 | amOnPage('/'); 13 | $I->seeResponseCodeIs(HttpCode::OK); 14 | $I->see('Partager de la musique. Facilement.'); 15 | } 16 | 17 | public function testAbout(AcceptanceTester $I) 18 | { 19 | $I->amOnPage('/about'); 20 | $I->seeResponseCodeIs(HttpCode::OK); 21 | $I->see("la Vie, tuneefy, l'Univers et le Reste"); 22 | } 23 | 24 | public function testTrends(AcceptanceTester $I) 25 | { 26 | $I->amOnPage('/trends'); 27 | $I->seeResponseCodeIs(HttpCode::OK); 28 | $I->see('Les sites de musique que vous utilisez'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Services/Platforms/Interfaces/GeneralPlatformInterface.php: -------------------------------------------------------------------------------- 1 | [['GENERAL_ERROR' => $exception->getMessage()]], 14 | ]; 15 | } 16 | 17 | public function supportsNormalization($data, ?string $format = null, array $context = []): bool 18 | { 19 | return $data instanceof FlattenException; 20 | } 21 | 22 | public function getSupportedTypes(?string $format): array 23 | { 24 | return [ 25 | FlattenException::class => __CLASS__ === self::class, 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in(__DIR__.'/src') 5 | ->in(__DIR__.'/tests') 6 | ->exclude(['_support', '_data', '_output']) 7 | ; 8 | 9 | return (new PhpCsFixer\Config()) 10 | ->setRules(array( 11 | '@Symfony' => true, 12 | 'ordered_imports' => true, // Order "use" alphabetically 13 | 'array_syntax' => ['syntax' => 'short'], // Replace array() by [] 14 | 'no_useless_return' => true, // Keep return null; 15 | 'phpdoc_order' => true, // Clean up the /** php doc */ 16 | 'linebreak_after_opening_tag' => true, 17 | 'multiline_whitespace_before_semicolons' => false, 18 | 'phpdoc_add_missing_param_annotation' => true, 19 | 'phpdoc_order' => true, 20 | )) 21 | ->setUsingCache(false) 22 | ->setFinder($finder) 23 | ; 24 | -------------------------------------------------------------------------------- /examples/search.requests.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | import requests, json 3 | 4 | key = 'administrator' 5 | secret = 'password' 6 | 7 | tokenEndpoint = 'https://data.tuneefy.com/v2/auth/token' 8 | searchEndpoint = 'https://data.tuneefy.com/v2/search/track/spotify?q=amon+tobin&limit=1' 9 | 10 | # 1. Request token 11 | payload = {'grant_type': 'client_credentials', 'client_id': key, 'client_secret': secret} 12 | req = requests.post(tokenEndpoint,data=payload) 13 | 14 | token = req.json() 15 | 16 | # 2. Use token for search on Spotify 17 | if token['token_type'] and token['token_type'] == 'Bearer': 18 | headers = {'Authorization': 'Bearer '+token['access_token'], 'Accept': 'application/json'} 19 | req = requests.get(searchEndpoint, headers=headers) 20 | 21 | # 3. Tada ! 22 | print "🎉" 23 | print json.dumps(req.json(), indent=4, separators=(',', ': ')) 24 | else: 25 | print "Wrong key/secret pair" 26 | -------------------------------------------------------------------------------- /src/Command/ExpiredIntentsCleanerCommand.php: -------------------------------------------------------------------------------- 1 | itemRepository->cleanExpiredIntents(); 25 | 26 | return Command::SUCCESS; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /assets/widget/widget-overlay.scss: -------------------------------------------------------------------------------- 1 | div#tuneefy_overlay { 2 | position: fixed; 3 | display: block; 4 | width: 450px; 5 | height: 180px; 6 | /*top: -180px;*/ 7 | top:0; 8 | right: -2px; 9 | background: #1c1c1c; 10 | border: 1px solid black; 11 | border-radius: 0 0 0 4px; 12 | box-shadow: 0 0 6px black; 13 | z-index: 1000 !important; 14 | } 15 | 16 | #tuneefy_overlay div.closeButton { 17 | position: absolute; 18 | display: block; 19 | top: 10px; 20 | right: 11px; 21 | background: url("//tuneefy.com/img/close.png") no-repeat scroll center top transparent; 22 | height: 32px; 23 | width: 32px; 24 | cursor: pointer; 25 | } 26 | 27 | #tuneefy_overlay div.closeButton:hover{ 28 | background-position: 0px -32px; 29 | } 30 | 31 | #tuneefy_overlay iframe{ 32 | border: none; 33 | background: transparent; 34 | height: 100%; 35 | width: 100%; 36 | } 37 | 38 | #tuneefy_overlay .middleBox{ 39 | position: relative; 40 | top: 66px; 41 | left: 200px; 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ###> symfony/framework-bundle ### 2 | /.env.local 3 | /.env.local.php 4 | /.env.*.local 5 | /config/secrets/prod/prod.decrypt.private.php 6 | /public/bundles/ 7 | /var/ 8 | /vendor/ 9 | ###< symfony/framework-bundle ### 10 | 11 | ###> phpunit/phpunit ### 12 | /phpunit.xml 13 | .phpunit.result.cache 14 | ###< phpunit/phpunit ### 15 | 16 | ###> symfony/phpunit-bridge ### 17 | .phpunit.result.cache 18 | /phpunit.xml 19 | ###< symfony/phpunit-bridge ### 20 | 21 | ###> symfony/asset-mapper ### 22 | /public/assets/ 23 | /assets/vendor/ 24 | ###< symfony/asset-mapper ### 25 | 26 | # Deploy files 27 | deploy/vars.yml 28 | deploy/*.prod.yml 29 | 30 | # Generic files 31 | .DS_Store 32 | TODO.* 33 | 34 | ## Frontend 35 | node_modules 36 | /web/build 37 | 38 | ###> friendsofphp/php-cs-fixer ### 39 | /.php-cs-fixer.php 40 | /.php-cs-fixer.cache 41 | ###< friendsofphp/php-cs-fixer ### 42 | 43 | ###> league/oauth2-server-bundle ### 44 | /config/jwt/*.pem 45 | ###< league/oauth2-server-bundle ### 46 | -------------------------------------------------------------------------------- /tests/Support/ApiTester.php: -------------------------------------------------------------------------------- 1 | writeln($message); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 6 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 7 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], 8 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 9 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 10 | Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], 11 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 12 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 13 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 14 | Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], 15 | League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true], 16 | ]; 17 | -------------------------------------------------------------------------------- /examples/search.request.js: -------------------------------------------------------------------------------- 1 | var request = require('request'); 2 | 3 | var key = 'administrator'; 4 | var secret = 'password'; 5 | 6 | var tokenEndpoint = 'https://data.tuneefy.com/v2/auth/token'; 7 | var searchEndpoint = 'https://data.tuneefy.com/v2/search/track/spotify?q=amon+tobin&limit=1'; 8 | 9 | // 1. Request token 10 | request.post({ 11 | url: tokenEndpoint, 12 | form: { 13 | 'grant_type': 'client_credentials', 14 | 'client_id': key, 15 | 'client_secret': secret 16 | } 17 | }, function(err, httpResponse, body) { 18 | var json = JSON.parse(body); 19 | 20 | if (json.token_type && json.token_type === 'Bearer') { 21 | // 2. Use token for search on Spotify 22 | request({ 23 | url: searchEndpoint, 24 | 'auth': { 25 | 'bearer': json.access_token 26 | }, 27 | method: 'GET' 28 | }, function(err, httpResponse, body) { 29 | var json = JSON.parse(body); 30 | 31 | // 3. Tada ! 32 | console.log("🎉"); 33 | console.log(JSON.stringify(json, null, 4)); 34 | }); 35 | } 36 | 37 | }); 38 | -------------------------------------------------------------------------------- /deploy/Caddyfile: -------------------------------------------------------------------------------- 1 | tuneefy.com, www.tuneefy.com { 2 | @redirwww { 3 | host www.tuneefy.com 4 | } 5 | redir @redirwww https://tuneefy.com{uri} permanent 6 | 7 | # Redirect API endpoints 8 | @redirapi path_regexp api_redir /api(/v2.*)$ 9 | redir @redirapi https://data.tuneefy.com{re.api_redir.1} permanent 10 | 11 | root * /path/to/project/public 12 | php_fastcgi 127.0.0.1:8000 13 | file_server { 14 | import hidden 15 | } 16 | import security 17 | } 18 | 19 | data.tuneefy.com { 20 | root * /path/to/project/public 21 | php_fastcgi 127.0.0.1:8000 22 | header { 23 | Access-Control-Allow-Origin https://tuneefy.com 24 | Access-Control-Allow-Credentials true 25 | Vary Origin 26 | } 27 | @options { 28 | method OPTIONS 29 | } 30 | header @options { 31 | Access-Control-Allow-Methods "GET, POST, OPTIONS, HEAD" 32 | Access-Control-Allow-Headers X-Requested-With 33 | } 34 | import security 35 | } -------------------------------------------------------------------------------- /src/Repository/ApiClientRepository.php: -------------------------------------------------------------------------------- 1 | getEntityManager()->getConnection(); 19 | 20 | $sql = ' 21 | SELECT apic.*, c.*, COUNT(`items`.`id`) AS `nb_items` 22 | FROM api_client apic 23 | LEFT JOIN oauth2_client c on apic.oauth2_client_identifier = c.identifier 24 | LEFT JOIN items ON items.client_id = c.identifier 25 | GROUP BY apic.id 26 | ORDER BY apic.created_at DESC 27 | '; 28 | 29 | $resultSet = $conn->executeQuery($sql); 30 | 31 | // returns an array of arrays (i.e. a raw data set) 32 | return $resultSet->fetchAllAssociative(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/EventSubscriber/LocaleSubscriber.php: -------------------------------------------------------------------------------- 1 | [ 21 | ['setLocaleFromCookie'], 22 | ], 23 | ]; 24 | } 25 | 26 | public function setLocaleFromCookie(RequestEvent $event): void 27 | { 28 | if (!$event->isMainRequest()) { 29 | // don't do anything if it's not the main request 30 | return; 31 | } 32 | 33 | $locale = $event->getRequest()->cookies->get('tuneefyLocale'); 34 | if ($locale) { 35 | $this->localeSwitcher->setLocale($locale); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Command/PlatformIpsFetcherCommand.php: -------------------------------------------------------------------------------- 1 | $value) { 30 | $ips = gethostbynamel($value); 31 | echo " '".$value.':443:'.$ips[0]."',\n"; 32 | } 33 | echo "];\n"; 34 | 35 | return Command::SUCCESS; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tuneefy", 3 | "version": "2.1.0", 4 | "author": "tchap", 5 | "license": "ISC", 6 | "description": "A new version of [tuneefy](http://tuneefy.com) built for PHP 7, from the ground up, using the minimal Slim framework and a few helper libraries.", 7 | "main": "index.js", 8 | "directories": { 9 | "test": "tests" 10 | }, 11 | "scripts": { 12 | "build": "gulp", 13 | "api-documentation": "./node_modules/aglio/bin/aglio.js --theme-full-width --theme-template triple --theme-variables streak --theme-style default --theme-style templates/api/api.less --no-theme-condense -i templates/api/main.apib -o templates/api.html" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/tchapi/tuneefy2.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/tchapi/tuneefy2/issues" 21 | }, 22 | "homepage": "https://github.com/tchapi/tuneefy2#readme", 23 | "devDependencies": { 24 | "aglio": "^2.3.0", 25 | "gulp": "^4.0.2", 26 | "gulp-concat": "^2.6.1", 27 | "gulp-sass": "^5.1.0", 28 | "gulp-uglify": "^3.0.2", 29 | "pump": "^3.0", 30 | "sass": "^1.54.9", 31 | "standard": "^17.0.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /assets/js/about.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function () { 2 | var u = $('ul.platformsPatterns li.platform') 3 | 4 | $('form.contactForm #send').click(function () { 5 | var e = $('form.contactForm #email') 6 | var m = $('form.contactForm #message') 7 | var c = $('form.contactForm #g-recaptcha-response') 8 | var action = $('form').attr('action') 9 | 10 | m.removeClass('error') 11 | e.removeClass('error') 12 | 13 | if (e.val() == '') { 14 | e.addClass('error') 15 | return false 16 | } 17 | 18 | if (m.val() == '') { 19 | m.addClass('error') 20 | return false 21 | } 22 | 23 | $('form.contactForm').hide() 24 | $('.waitingMail').show() 25 | 26 | $.post(action, { mail: e.val(), message: m.val(), captcha: c.val() }, function (data) { 27 | $('.waitingMail').hide() 28 | 29 | if (data == '1') { 30 | $('.successMail').show() 31 | } else { 32 | $('.errorMail').show() 33 | } 34 | }) 35 | }) 36 | 37 | u.click(function (e) { 38 | if (!$(e.target).hasClass('platform')) return 39 | u.removeClass('active') 40 | $(e.target).addClass('active') 41 | u.find('ul').hide() 42 | $(e.target).find('ul').show() 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /examples/search.curl.php: -------------------------------------------------------------------------------- 1 | token_type) && $token->token_type === 'Bearer') { 21 | $br = curl_init($searchEndpoint); 22 | curl_setopt($br, CURLOPT_HTTPHEADER, [ 23 | 'Authorization: Bearer '.$token->access_token, 24 | 'Accept: application/json', 25 | ]); 26 | curl_setopt($br, CURLOPT_RETURNTRANSFER, true); 27 | $data = curl_exec($br); 28 | curl_close($br); 29 | 30 | // 3. Tada ! 31 | echo "🎉\n"; 32 | var_dump($data); 33 | } else { 34 | echo "Wrong key/secret pair"; 35 | } 36 | -------------------------------------------------------------------------------- /src/Controller/SecurityController.php: -------------------------------------------------------------------------------- 1 | getUser()) { 17 | return $this->redirectToRoute('admin_dashboard'); 18 | } 19 | 20 | // get the login error if there is one 21 | $error = $authenticationUtils->getLastAuthenticationError(); 22 | // last username entered by the user 23 | $lastUsername = $authenticationUtils->getLastUsername(); 24 | 25 | return $this->render('admin/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); 26 | } 27 | 28 | #[Route('/admin/logout', name: 'admin_logout')] 29 | public function logout() 30 | { 31 | throw new \Exception('This method can be blank - it will be intercepted by the logout key on your firewall'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | tests 23 | 24 | 25 | 26 | 27 | 28 | src 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /assets/scss/partials/reset.scss: -------------------------------------------------------------------------------- 1 | /* Based on http://meyerweb.com/eric/tools/css/reset/ */ 2 | html, body, div, span, applet, object, iframe, 3 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 4 | a, abbr, acronym, address, big, cite, code, 5 | del, dfn, em, img, ins, kbd, q, s, samp, 6 | small, strike, strong, sub, sup, tt, var, 7 | b, u, i, center, 8 | dl, dt, dd, ol, ul, li, 9 | fieldset, form, label, legend, 10 | table, caption, tbody, tfoot, thead, tr, th, td, 11 | article, aside, canvas, details, embed, 12 | figure, figcaption, footer, header, hgroup, 13 | menu, nav, output, ruby, section, summary, 14 | time, mark, audio, video { 15 | margin: 0; 16 | padding: 0; 17 | border: 0; 18 | font-size: 100%; 19 | font: inherit; 20 | vertical-align: baseline; 21 | } 22 | /* HTML5 display-role reset for older browsers */ 23 | article, aside, details, figcaption, figure, 24 | footer, header, hgroup, menu, nav, section { 25 | display: block; 26 | } 27 | body { 28 | line-height: 1; 29 | } 30 | ol, ul { 31 | list-style: none; 32 | } 33 | blockquote, q { 34 | quotes: none; 35 | } 36 | blockquote:before, blockquote:after, 37 | q:before, q:after { 38 | content: ''; 39 | content: none; 40 | } 41 | table { 42 | border-collapse: collapse; 43 | border-spacing: 0; 44 | } 45 | 46 | a, a:link { text-decoration: none; } 47 | 48 | input, textarea{ 49 | outline: none; /* for Safari by tchap */ 50 | } -------------------------------------------------------------------------------- /public/build/js/trends.js: -------------------------------------------------------------------------------- 1 | Raphael.fn.pieChart=function(M,$,g,v,x,t){var y=this,C=Math.PI/180,k=this.set();function a(t){var a,e,o,i,n,s,h,r,p,c,l=360*v[t]/z,f=R+l/2,u=500,m=(a=M,e=$,o=g,n=(i=R)+l,s={fill:Raphael.color("#"+x[t][2]),stroke:"none"},h=a+o*Math.cos(-i*C),r=a+o*Math.cos(-n*C),p=e+o*Math.sin(-i*C),c=e+o*Math.sin(-n*C),y.path(["M",a,e,"L",h,p,"A",o,o,0,+(180 'ARTIST - TITLE [Official Video]', 15 | 'result' => 'TITLE', 16 | ], 17 | [ 18 | 'source' => 'ARTIST - TITLE (Official)', 19 | 'result' => 'TITLE', 20 | ], 21 | [ 22 | 'source' => 'ARTIST - TITLE (Officiel)', 23 | 'result' => 'TITLE', 24 | ], 25 | [ 26 | 'source' => 'ARTIST - TITLE (CLIP OFFICIEL)', 27 | 'result' => 'TITLE', 28 | ], 29 | [ 30 | 'source' => 'ARTIST - TITLE (CLIP)', 31 | 'result' => 'TITLE', 32 | ], 33 | [ 34 | 'source' => 'ARTIST - TITLE (video officielle)', 35 | 'result' => 'TITLE', 36 | ], 37 | [ 38 | 'source' => 'ARTIST - TITLE (vidéo officielle)', 39 | 'result' => 'TITLE', 40 | ], 41 | ]; 42 | 43 | public function testTitleParsing() 44 | { 45 | $platform = new YoutubePlatform('test', 'test'); 46 | 47 | foreach ($this->strings as $str) { 48 | $this->assertEquals( 49 | $str['result'], 50 | $platform->parseYoutubeMusicVideoTitle($str['source'])[0] 51 | ); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/EventSubscriber/ApiStatsSubscriber.php: -------------------------------------------------------------------------------- 1 | isMainRequest()) { 22 | // don't do anything if it's not the main request 23 | return; 24 | } 25 | 26 | $request = $event->getRequest(); 27 | 28 | if (!str_starts_with($request->getRequestUri(), '/api/v2/')) { 29 | // Only api requests 30 | return; 31 | } 32 | 33 | $token = $this->security->getToken(); 34 | 35 | if (!$token) { 36 | return; 37 | } 38 | 39 | $method = $request->attributes->get('api_method'); 40 | 41 | $clientId = $token->getAttribute('oauth_client_id'); 42 | 43 | if ($method && $clientId) { 44 | $this->statsService->addApiCallingStat($method, $clientId); 45 | } 46 | } 47 | 48 | public static function getSubscribedEvents(): array 49 | { 50 | return [ 51 | KernelEvents::TERMINATE => 'onTerminate', 52 | ]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /templates/admin/_base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Administration 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 27 | 28 |
29 | {% block content %} 30 | {% endblock %} 31 |
32 |
33 | 34 | {% block javascript %} 35 | 39 | 40 | 41 | 42 | {% endblock %} 43 | 44 | -------------------------------------------------------------------------------- /tests/Acceptance/BackendCest.php: -------------------------------------------------------------------------------- 1 | stopFollowingRedirects(); 13 | 14 | $I->amOnPage('/admin/dashboard'); 15 | $I->seeResponseCodeIsRedirection(); 16 | $I->haveHttpHeader('Location', '/admin/login'); 17 | 18 | $I->amOnPage('/admin/api/clients'); 19 | $I->seeResponseCodeIsRedirection(); 20 | $I->haveHttpHeader('Location', '/admin/login'); 21 | 22 | $I->amOnPage('/admin/api/clients/new'); 23 | $I->seeResponseCodeIsRedirection(); 24 | $I->haveHttpHeader('Location', '/admin/login'); 25 | 26 | $I->amOnPage('/admin/login'); 27 | $I->seeResponseCodeIs(HttpCode::OK); 28 | } 29 | 30 | public function testLogin(AcceptanceTester $I) 31 | { 32 | $I->amOnPage('/admin/login'); 33 | $I->seeResponseCodeIs(HttpCode::OK); 34 | 35 | $I->submitForm('#loginForm', [ 36 | '_username' => 'admin', 37 | '_password' => 'test', 38 | ], 'submitButton'); 39 | 40 | $I->seeResponseCodeIs(HttpCode::OK); 41 | 42 | $I->dontSee('Log-in'); 43 | $I->see('Tracks & Albums'); 44 | 45 | $I->amOnPage('/admin/api/clients'); 46 | $I->seeResponseCodeIs(HttpCode::OK); 47 | $I->see('Create new client'); 48 | 49 | $I->amOnPage('/admin/api/clients/new'); 50 | $I->seeResponseCodeIs(HttpCode::OK); 51 | $I->see('Create a new client'); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /templates/admin/dashboard.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'admin/_base.html.twig' %} 2 | {% set menu = "dashboard" %} 3 | 4 | {% block content %} 5 |

Dashboard

6 | 7 |

Tracks & Albums

8 |
9 |
10 |
11 | {{ itemsStats.total|number_format(0, '.', ' ') }} 12 |
13 |
14 | Items total 15 |
16 |
17 |
18 |
19 | {{ itemsStats.tracks|number_format(0, '.', ' ') }} 20 |
21 |
22 | Tracks 23 |
24 |
25 |
26 |
27 | {{ (itemsStats.total - itemsStats.tracks)|number_format(0, '.', ' ') }} 28 |
29 |
30 | Albums 31 |
32 |
33 |
34 | 35 |

API

36 |
37 |
38 |
{{ activeClients|length }}
39 |
Active Clients
40 |
41 | {% for key, stat in apiStats %} 42 |
43 |
{{ stat }}
44 |
/{{ key }}
45 |
46 | {% endfor %} 47 |
48 | {% endblock %} -------------------------------------------------------------------------------- /public/build/css/embed.css: -------------------------------------------------------------------------------- 1 | body{font-size:.7em}#footer-wrapper,#header-wrapper{display:none}.wrap{width:auto;margin:0}.wrap.bdBot,#trackInfo.bdBot{border:none}.Bcolor{background:rgba(0,0,0,0)}#wrapper{background:#151515;border-radius:3px;position:relative;box-shadow:0 1px #070707 inset,0 1px #333}#tagline{height:36px;padding:0}h1.logo{display:inline-block;margin-left:-6px;margin-top:-6px}h1.logo>img{width:100px;height:auto}p.tagline{float:right;padding-top:12px;padding-right:10px;font-size:1em;color:dimgray}#content{padding:0}#mainTitle{font-size:1.7em;text-align:center;padding-top:50px}#mainContent{width:100%;margin:0}#trackInfo{height:90px;padding:8px}#trackInfo .cover{float:left;position:relative;margin:0 8px 0 0}#trackInfo .coverlay{width:80px;height:80px}#trackInfo .info{margin:0}#trackInfo .infoContent{vertical-align:middle}#trackInfo .title{font-weight:bold;font-size:1.5em;line-height:.9em;color:#a2a2a2;padding-top:4px}#trackInfo .artist{font-size:1.2em;line-height:1.3em;color:#474747;padding-top:0}#trackInfo .album{font-size:.75em;color:#474747;text-transform:uppercase}#platforms,#share{padding:0px}#platforms{padding-top:1px}#platforms .listenTitle,#share .mainTitle{text-transform:uppercase;font-size:.75em;color:#aaa;margin-bottom:5px}#platforms .btns_full{margin-right:3px;margin-bottom:0px}#linkHolder,#externalPlatformsActions{background:#1f1f1f;float:left;border:1px solid #000;padding:8px;position:relative;border-radius:3px}#linkHolder{margin-right:10px}.newWindow{opacity:.9;margin-left:3px}@media(max-width: 500px){h1.logo{width:auto}#tagline{text-align:left}#tagline p{float:right;padding-top:12px;padding-right:10px}#trackInfo{height:auto;overflow:auto}#trackInfo .info{width:auto}}@media(max-width: 310px){#tagline{height:auto}#tagline p{display:block;padding:0;float:none;margin:0 0 8px 12px}} -------------------------------------------------------------------------------- /deploy/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | 3 | server_name tuneefy.com; 4 | 5 | index index.php; 6 | root /home/tchap/www/tuneefy2/current/web; 7 | 8 | error_log /var/log/nginx/tuneefy2.error.log; 9 | set $app "tuneefy2"; 10 | access_log /var/log/nginx/all.access.log custom; 11 | 12 | location / { 13 | try_files $uri /index.php$is_args$args; 14 | } 15 | 16 | # Pass on to HHVM 17 | include php-fpm.conf; 18 | 19 | # Favicons and robots 20 | include favicon.robots.conf; 21 | 22 | # deny access to .htaccess files 23 | location ~ /\.ht { 24 | deny all; 25 | } 26 | 27 | } 28 | 29 | 30 | server { 31 | server_name data.tuneefy.com; 32 | 33 | index api.php; 34 | root /home/tchap/www/tuneefy2/current/web; 35 | 36 | error_log /var/log/nginx/api.tuneefy2.error.log; 37 | set $app "api-tuneefy2"; 38 | access_log /var/log/nginx/all.access.log custom; 39 | 40 | add_header 'Access-Control-Allow-Origin' "http://tuneefy.com"; 41 | add_header "Access-Control-Allow-Credentials" "true"; 42 | 43 | location / { 44 | 45 | # Preflighted requests 46 | if ($request_method = OPTIONS ) { 47 | add_header 'Access-Control-Allow-Origin' "http://tuneefy.com"; 48 | add_header "Access-Control-Allow-Credentials" "true"; 49 | add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; 50 | add_header "Access-Control-Allow-Headers" "X-Requested-With"; 51 | return 200; 52 | } 53 | 54 | try_files $uri /api.php$is_args$args; 55 | } 56 | 57 | # Pass on to HHVM 58 | include php-fpm.conf; 59 | 60 | # Favicons and robots 61 | include favicon.robots.conf; 62 | 63 | # deny access to .htaccess files 64 | location ~ /\.ht { 65 | deny all; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | ###> symfony/framework-bundle ### 2 | APP_ENV=dev 3 | APP_SECRET=4e544d8e15196020ef6cbf5526bc42d0 4 | ###< symfony/framework-bundle ### 5 | 6 | API_HOST=data.tuneefy.com 7 | WEBSITE_HOST=tuneefy.com 8 | 9 | ###> doctrine/doctrine-bundle ### 10 | DATABASE_URL="mysql://tuneefy:tuneefy@127.0.0.1:3306/tuneefy?serverVersion=10.11.2-MariaDB&charset=utf8mb4" 11 | ###< doctrine/doctrine-bundle ### 12 | 13 | ###> symfony/mailer ### 14 | MAILER_DSN="smtp://user:pass@smtp.domain.com:25" 15 | ###< symfony/mailer ### 16 | 17 | # Login for the administration dashboard 18 | ADMIN_LOGIN=admin 19 | ADMIN_PASSWORD=test 20 | 21 | # Google analytics 22 | GA_TRACKER_ID="00" 23 | 24 | # Captcha (mail sending from the about page) 25 | CAPTCHA_KEY="6L....." 26 | CAPTCHA_SECRET="6Ld......" 27 | 28 | # The identifier of the client to use to generate tokens when 29 | # bypassing the OAuth (for the tuneefy website) 30 | API_BYPASS_CLIENT_IDENTIFIER="create_an_oauth2_client_and_fetch_the_identifier" 31 | 32 | # Secret used to sign the intents (searches before they get persisted) 33 | INTENTS_SECRET="change_me_too" 34 | 35 | # Platforms 36 | DEEZER_KEY="" 37 | DEEZER_SECRET="" 38 | 39 | SPOTIFY_KEY="" 40 | SPOTIFY_SECRET="" 41 | 42 | QOBUZ_KEY="" 43 | QOBUZ_SECRET="" 44 | 45 | LASTFM_KEY="" 46 | LASTFM_SECRET="" 47 | 48 | SOUNDCLOUD_KEY="" 49 | SOUNDCLOUD_SECRET="" 50 | 51 | YOUTUBE_KEY="" 52 | YOUTUBE_SECRET="" 53 | 54 | MIXCLOUD_KEY="" 55 | MIXCLOUD_SECRET="" 56 | 57 | ITUNES_KEY="" 58 | ITUNES_SECRET="" 59 | 60 | TIDAL_KEY="" 61 | TIDAL_SECRET="" 62 | 63 | NAPSTER_KEY="" 64 | NAPSTER_SECRET="" 65 | 66 | ###> league/oauth2-server-bundle ### 67 | OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/jwt/private.pem 68 | OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem 69 | OAUTH_PASSPHRASE= 70 | OAUTH_ENCRYPTION_KEY=changeMe 71 | ###< league/oauth2-server-bundle ### 72 | -------------------------------------------------------------------------------- /config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: '%env(DATABASE_URL)%' 4 | 5 | # IMPORTANT: You MUST configure your server version, 6 | # either here or in the DATABASE_URL env var (see .env file) 7 | #server_version: '16' 8 | 9 | profiling_collect_backtrace: '%kernel.debug%' 10 | use_savepoints: true 11 | 12 | schema_filter: ~^(?!stats_|_mv)$~ 13 | orm: 14 | auto_generate_proxy_classes: true 15 | enable_lazy_ghost_objects: true 16 | report_fields_where_declared: true 17 | validate_xml_mapping: true 18 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 19 | auto_mapping: true 20 | controller_resolver: 21 | auto_mapping: false 22 | mappings: 23 | App: 24 | type: attribute 25 | is_bundle: false 26 | dir: '%kernel.project_dir%/src/Entity' 27 | prefix: 'App\Entity' 28 | alias: App 29 | 30 | when@test: 31 | doctrine: 32 | dbal: 33 | # "TEST_TOKEN" is typically set by ParaTest 34 | dbname_suffix: '_test%env(default::TEST_TOKEN)%' 35 | 36 | when@prod: 37 | doctrine: 38 | orm: 39 | auto_generate_proxy_classes: false 40 | proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' 41 | query_cache_driver: 42 | type: pool 43 | pool: doctrine.system_cache_pool 44 | result_cache_driver: 45 | type: pool 46 | pool: doctrine.result_cache_pool 47 | 48 | framework: 49 | cache: 50 | pools: 51 | doctrine.result_cache_pool: 52 | adapter: cache.app 53 | doctrine.system_cache_pool: 54 | adapter: cache.system 55 | -------------------------------------------------------------------------------- /src/Security/AdminUser.php: -------------------------------------------------------------------------------- 1 | username = $username; 16 | $this->password = $password; 17 | } 18 | 19 | /** 20 | * @return (Role|string)[] The user roles 21 | */ 22 | public function getRoles(): array 23 | { 24 | return ['ROLE_ADMIN']; 25 | } 26 | 27 | /** 28 | * Returns the password used to authenticate the user. 29 | */ 30 | public function getPassword(): string 31 | { 32 | return $this->password; 33 | } 34 | 35 | /** 36 | * Returns the salt that was originally used to encode the password. 37 | * 38 | * This can return null if the password was not encoded using a salt. 39 | * 40 | * @return string|null The salt 41 | */ 42 | public function getSalt() 43 | { 44 | return null; 45 | } 46 | 47 | /** 48 | * Returns the username used to authenticate the user. 49 | * 50 | * @return string The username 51 | */ 52 | public function getUsername() 53 | { 54 | return $this->username; 55 | } 56 | 57 | public function getUserIdentifier(): string 58 | { 59 | return $this->username; 60 | } 61 | 62 | /** 63 | * Removes sensitive data from the user. 64 | * 65 | * This is important if, at any given point, sensitive information like 66 | * the plain-text password is stored on this object. 67 | */ 68 | public function eraseCredentials(): void 69 | { 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Utils/ApiUtils.php: -------------------------------------------------------------------------------- 1 | query->get('format') ?? $request->getPreferredFormat(); 21 | 22 | if (!in_array($format, self::ACCEPTABLE_FORMATS)) { 23 | $format = 'json'; 24 | } 25 | 26 | $mimeType = $request->getMimeType($format); 27 | 28 | if ($apiMethod) { 29 | $request->attributes->set('api_method', $apiMethod); 30 | } 31 | 32 | return new Response($this->serializer->serialize($data, $format), $httpStatus, [ 33 | 'Content-type' => $mimeType, 34 | ]); 35 | } 36 | 37 | public function createGenericErrorResponse(Request $request, string $error_code, int $httpStatus = 400): Response 38 | { 39 | return $this->createFormattedResponse($request, [ 40 | 'errors' => [PlatformEngine::ERRORS[$error_code]], 41 | ], $httpStatus); 42 | } 43 | 44 | public function createUnhandledErrorResponse(Request $request, string $message, int $httpStatus = 400): Response 45 | { 46 | return $this->createFormattedResponse($request, [ 47 | 'errors' => [['GENERAL_ERROR' => $message]], 48 | ], $httpStatus); 49 | } 50 | 51 | public function createUpstreamErrorResponse(Request $request, array $errorPayload, int $httpStatus = 400): Response 52 | { 53 | return $this->createFormattedResponse($request, $errorPayload, $httpStatus); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /public/build/js/vendor/tablesort.js: -------------------------------------------------------------------------------- 1 | !function(u){u.tablesort=function(t,e){var s=this;this.$table=t,this.$thead=this.$table.find("thead"),this.settings=u.extend({},u.tablesort.defaults,e),this.$sortCells=(0 7 | Already logged in, logout 8 | 9 | {% else %} 10 |
11 |
12 |

13 |
14 | Log-in 15 |
16 |

17 | {% if error %} 18 |
19 |
20 | Error 21 |
22 | {{ error.messageKey|trans(error.messageData, 'security') }} 23 |
24 | {% endif %} 25 |
26 |
27 |
28 |
29 | 30 | 31 |
32 |
33 |
34 |
35 | 36 | 37 |
38 |
39 | 40 | 41 |
42 | 43 |
44 | 45 |
46 |
47 |
48 | {% endif %} 49 | {% endblock %} 50 | -------------------------------------------------------------------------------- /src/Security/AdminUserProvider.php: -------------------------------------------------------------------------------- 1 | { 11 | if (error) { 12 | console.log(error); 13 | } 14 | } 15 | 16 | gulp.task('javascript', function (done) { 17 | pump([ 18 | gulp.src(resourcesFolder + 'js/**/*.js'), 19 | uglify(), 20 | gulp.dest(buildFolder + 'js') 21 | ], 22 | log, 23 | done 24 | ); 25 | }); 26 | 27 | gulp.task('twig', function (done) { 28 | pump([ 29 | gulp.src(resourcesFolder + 'js/**/*.twig'), 30 | gulp.dest(buildFolder + 'js') 31 | ], 32 | log, 33 | done 34 | ); 35 | }); 36 | 37 | gulp.task('sass', function (done) { 38 | pump([ 39 | gulp.src(resourcesFolder + 'scss/styles.scss'), 40 | sass({ outputStyle: 'compressed' }).on('error', sass.logError), 41 | gulp.dest(buildFolder + 'css') 42 | ], 43 | log, 44 | done 45 | ); 46 | }); 47 | 48 | gulp.task('embed', function (done) { 49 | pump([ 50 | gulp.src(resourcesFolder + 'scss/partials/embed.scss'), 51 | sass({ outputStyle: 'compressed' }).on('error', sass.logError), 52 | gulp.dest(buildFolder + 'css') 53 | ], 54 | log, 55 | done 56 | ); 57 | }); 58 | 59 | gulp.task('widget', function (done) { 60 | pump([ 61 | gulp.src([resourcesFolder + 'scss/partials/reset.scss', resourcesFolder + 'widget/widget.scss']), 62 | concat('widget.scss'), 63 | sass({ outputStyle: 'compressed' }).on('error', sass.logError), 64 | gulp.dest(buildFolder + 'css') 65 | ], 66 | log 67 | ); 68 | pump([ 69 | gulp.src(resourcesFolder + 'widget/widget-overlay.scss'), 70 | sass({ outputStyle: 'compressed' }).on('error', sass.logError), 71 | gulp.dest(buildFolder + 'css') 72 | ], 73 | log 74 | ); 75 | pump([ 76 | gulp.src(resourcesFolder + 'widget/widget.js'), 77 | uglify(), 78 | gulp.dest(buildFolder + 'js') 79 | ], 80 | log, 81 | done 82 | ); 83 | }); 84 | 85 | gulp.task('default', gulp.parallel('widget', 'embed', 'sass', 'javascript', 'twig')); 86 | -------------------------------------------------------------------------------- /templates/admin/clients.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'admin/_base.html.twig' %} 2 | {% set menu = "clients" %} 3 | 4 | {% block content %} 5 |

API Clients

6 | 7 |

Actions

8 |

Create new client

9 | 10 |

List

11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for client in clients %} 24 | {% set revoked = client.active?"":" class='negative'" %} 25 | 26 | 27 | 28 | 32 | 33 | 34 | 41 | 42 | {% endfor %} 43 | 44 |
Name SinceDescription KeyItemsCalls
{{ client.name }}{{ client.created_at|date('d/m/y') }} 29 | {% if client.description %}{{ client.description|default('N/A')}}
{% endif %} 30 | {% if client.url %}{{ client.url }}
{% endif %} 31 | {{ client.email }}
{{ client.identifier }}{{ client.nb_items|number_format(0,'.',' ') }} 35 | {% if stats[client.identifier] is defined %} 36 | {% for item in stats[client.identifier] %} 37 | {{ item['method'] }} → {{ item['count']|number_format(0,'.',' ') }}
38 | {% endfor %} 39 | {% endif %} 40 |
45 | {% endblock %} 46 | 47 | {% block javascript %} 48 | {{ parent() }} 49 | base), 10, 36); 17 | } 18 | 19 | public function fromUId(string $uid): int 20 | { 21 | return intval(base_convert($uid, 36, 10) / $this->base); 22 | } 23 | 24 | /* 25 | Sanitizes a string 26 | Removes any non Latin character by its equivalent in ASCII Latin 27 | */ 28 | public static function sanitize(string $string): string 29 | { 30 | $string = transliterator_transliterate('Any-Latin; Latin-ASCII', $string); 31 | 32 | return strtolower(preg_replace('/[^\w\-]+/u', '-', $string)); 33 | } 34 | 35 | /* 36 | Returns an ellipsed version of $text 37 | */ 38 | public static function ellipsis(string $text, int $max = 100, string $append = '…'): string 39 | { 40 | if (strlen($text) <= $max) { 41 | return $text; 42 | } 43 | 44 | // Cut the string 45 | $out = trim(substr($text, 0, $max)); 46 | 47 | if (false === strpos($text, ' ')) { 48 | // If it's a single word, just return with the suffix 49 | return $out.$append; 50 | } else { 51 | // Else, we replace the last word with the suffix 52 | return substr($out, 0, strrpos($out, ' ') + 1).$append; 53 | } 54 | } 55 | 56 | /* 57 | Removes a bom from a string 58 | */ 59 | public static function removeBOM(string $string): string 60 | { 61 | $bom = pack('H*', 'EFBBBF'); 62 | 63 | return preg_replace("/^$bom/", '', $string); 64 | } 65 | 66 | public static function flattenMetaXMLNodes(string $xml): string 67 | { 68 | return preg_replace('/(.*)<\/meta>/', '<$1>$2', $xml); 69 | } 70 | 71 | /* 72 | When a platform does not return a "score" in a search session, 73 | we create a fake one using this function 74 | */ 75 | public static function indexScore(int $index): float 76 | { 77 | return round(1 / ($index / 10 + 1), 2); 78 | } 79 | 80 | /* 81 | Flattens tokens (an array of strings) to return a single "alphanumeric" string 82 | */ 83 | public static function flatten(array $tokens): string 84 | { 85 | return preg_replace('/[^a-z0-9]+/', '', strtolower(implode('', $tokens))); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/Unit/UtilsTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( 18 | $utils->toUid(1000), 19 | $result 20 | ); 21 | 22 | $this->assertEquals( 23 | $utils->fromUid($result), 24 | 1000 25 | ); 26 | } 27 | 28 | public function testSanitize() 29 | { 30 | $string = "Je suis une chaîne prête pour la sanitization n'est-ce pas ?"; 31 | 32 | $this->assertEquals( 33 | Utils::sanitize($string), 34 | 'je-suis-une-chaine-prete-pour-la-sanitization-n-est-ce-pas-' 35 | ); 36 | } 37 | 38 | public function testEllipsis() 39 | { 40 | $string = "Je suis une chaîne prête pour l'ellipse n'est-ce pas ?"; 41 | 42 | $this->assertEquals( 43 | Utils::ellipsis($string), 44 | "Je suis une chaîne prête pour l'ellipse n'est-ce pas ?" 45 | ); 46 | 47 | $this->assertEquals( 48 | Utils::ellipsis($string, 12, '...'), 49 | 'Je suis ...' 50 | ); 51 | 52 | $this->assertEquals( 53 | Utils::ellipsis($string, 14, '...'), 54 | 'Je suis une ...' 55 | ); 56 | } 57 | 58 | public function testFlattenMetaXMLNodes() 59 | { 60 | $xml = '2Chainz'; 61 | $this->assertEquals( 62 | Utils::flattenMetaXMLNodes($xml), 63 | '2Chainz' 64 | ); 65 | 66 | $xml = '2Chainz'; 67 | $this->assertEquals( 68 | Utils::flattenMetaXMLNodes($xml), 69 | '2Chainz' 70 | ); 71 | } 72 | 73 | public function testIndexScore() 74 | { 75 | $this->assertEquals( 76 | Utils::indexScore(0), 77 | 1 78 | ); 79 | 80 | $this->assertEquals( 81 | Utils::indexScore(10), 82 | 0.5 83 | ); 84 | 85 | $this->assertEquals( 86 | Utils::indexScore(100), 87 | 0.09 88 | ); 89 | } 90 | 91 | public function testFlatten() 92 | { 93 | $tokens = ['token1', 'token2', 'other Token']; 94 | 95 | $this->assertEquals( 96 | Utils::flatten($tokens), 97 | 'token1token2othertoken' 98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /assets/js/trends.js: -------------------------------------------------------------------------------- 1 | Raphael.fn.pieChart = function (cx, cy, r, values, labels, stroke) { 2 | // We create the paper 3 | var paper = this 4 | 5 | var rad = Math.PI / 180 6 | 7 | var chart = this.set() 8 | 9 | // Function to create a sector 10 | function sector(cx, cy, r, startAngle, endAngle, params) { 11 | var x1 = cx + r * Math.cos(-startAngle * rad) 12 | 13 | var x2 = cx + r * Math.cos(-endAngle * rad) 14 | 15 | var y1 = cy + r * Math.sin(-startAngle * rad) 16 | 17 | var y2 = cy + r * Math.sin(-endAngle * rad) 18 | return paper.path(['M', cx, cy, 'L', x1, y1, 'A', r, r, 0, +(endAngle - startAngle > 180), 0, x2, y2, 'z']).attr(params) 19 | }; 20 | 21 | var angle = 0 22 | 23 | var total = 0 24 | 25 | var start = 0 26 | 27 | var process = function (j) { 28 | var value = values[j] 29 | 30 | var angleplus = 360 * value / total 31 | 32 | var popangle = angle + (angleplus / 2) 33 | 34 | var ms = 500 35 | 36 | var delta = 20 37 | 38 | var p = sector(cx, cy, r, angle, angle + angleplus, { fill: Raphael.color('#' + labels[j][2]), stroke: 'none' }) 39 | 40 | var txt = paper.text(cx + (r + delta + 55) * Math.cos(-popangle * rad), cy + (r + delta + 25) * Math.sin(-popangle * rad), labels[j][1]).attr({ fill: Raphael.color('#' + labels[j][2]), stroke: 'none', opacity: 0, 'font-size': 16 }) 41 | if (angleplus > 13) { paper.image('/img/platforms/platform_chart_' + labels[j][0] + '.png', cx + (r * 0.65) * Math.cos(-popangle * rad) - 18, cy + (r * 0.65) * Math.sin(-popangle * rad) - 15, 36, 30) } 42 | p.mouseover(function () { 43 | p.stop().animate({ transform: 's1.1 1.1 ' + cx + ' ' + cy }, ms, 'elastic') 44 | txt.stop().animate({ opacity: 1 }, ms, 'elastic') 45 | }).mouseout(function () { 46 | p.stop().animate({ transform: '' }, ms, 'elastic') 47 | txt.stop().animate({ opacity: 0 }, ms) 48 | }) 49 | angle += angleplus 50 | chart.push(p) 51 | chart.push(txt) 52 | start += 0.1 53 | } 54 | 55 | // For each value, we create the corresponding sector 56 | for (var i = 0, ii = values.length; i < ii; i++) { 57 | total += values[i] 58 | } 59 | for (i = 0; i < ii; i++) { 60 | process(i) 61 | } 62 | 63 | // Underlying grey circle 64 | paper.circle(cx, cy, r + 10).attr({ fill: '#2d2d2d', stroke: 'none' }).toBack() 65 | 66 | return chart 67 | } 68 | 69 | $(document).ready(function () { 70 | var values = []; var labels = [] 71 | $('table#pieData tr').each(function () { 72 | values.push(parseInt($('td', this).text(), 10)) 73 | labels.push(new Array($('th span.id', this).text(), $('th span.name', this).text(), $('th span.color', this).text())) 74 | }) 75 | var w = $('#pieChart').outerWidth() 76 | var h = $('#pieChart').outerHeight() 77 | Raphael('pieChart', w, 500).pieChart(w / 2, 250, Math.min(h / 2.5, w / 2.5), values, labels, '#2d2d2d') 78 | }) 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tuneefy _2_ 2 | 3 | A new version of [tuneefy](http://tuneefy.com) built for **PHP 8** and **Node 18+**, from the ground up, using Symfony and a few helper libraries. 4 | 5 | ### Installing 6 | 7 | This project uses [composer 2](https://getcomposer.org/). Just run : 8 | 9 | composer install 10 | 11 | ### Creating tables 12 | 13 | Tuneefy needs a variety of tables to work properly; you can populate your database with the following : 14 | 15 | bin/console doctrine:migrations:migrate 16 | 17 | ### Building assets & API doc 18 | 19 | To build the assets and the API documentation, I use **yarn** and some modules. 20 | 21 | yarn install 22 | 23 | yarn run build 24 | yarn run api-documentation 25 | 26 | ### Tests 27 | 28 | The tests are under the `./tests` folder and I use **Codeception** to run them. 29 | 30 | Just run : 31 | 32 | vendor/bin/codecept run --steps 33 | 34 | Beforehand, do not forget to launch a development web server so that the functional tests have an endpoint to test: 35 | 36 | symfony server:start --port 9999 37 | 38 | There should be 40 tests containing 697 assertions. 39 | 40 | > Sometimes a platform fails to respond correctly due to network latencies or such. Re-run the tests in this case, it should pass fine the second time. 41 | 42 | ### API 43 | 44 | The API endpoints require an OAuth access token. The token is necessary to authenticate **all** requests to the API. 45 | 46 | The tuneefy API currently supports the [OAuth 2 draft](https://oauth.net/2/) specification. All OAuth2 requests MUST use the SSL endpoint available at https://data.tuneefy.com/. 47 | 48 | OAuth 2.0 is a simple and secure authentication mechanism. It allows applications to acquire an access token for tuneefy via a POST request to a token endpoint. Authentication with OAuth can be accomplished in the following steps: 49 | 50 | 1. Register for an API key by sending a mail to api@tuneefy.com 51 | 2. Exchange your customer id and secret for an access token 52 | 3. Make requests by passing the token in the Authorization header 53 | 4. When your token expires, you can get a new one 54 | 55 | #### Apply for an API key 56 | 57 | You can get an API key and associated secret by sending an email to api@tuneefy.com. 58 | 59 | #### Web Service Rate Limits 60 | 61 | Limits are placed on the number of API requests you may make using your API key. Rate limits may vary by service, but the defaults are 100 requests per hour. 62 | 63 | #### Full documentation 64 | 65 | The full documentation is available at https://data.tuneefy.com. An API blueprint is also available [here](https://github.com/tchapi/tuneefy2/blob/master/app/templates/api/main.apib) — use your preferred renderer to build it. We use Aglio. 66 | 67 | 68 | - - - 69 | 70 | > If you want to participate/contribute, feel free to create pull requests or issues so we can make Tuneefy better and more efficient ! 71 | -------------------------------------------------------------------------------- /templates/_widget.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | 21 | 22 |
23 |
24 | {% if params.statistics.ga_tracker_id > 0 %} 25 | 36 | {% endif %} 37 | 38 | 39 | 48 | 49 | 50 | 60 | -------------------------------------------------------------------------------- /public/build/js/widget.js: -------------------------------------------------------------------------------- 1 | var host="http://localhost:1234";function addCSS(e){var n=document.getElementsByTagName("head")[0],l=document.createElement("link");l.type="text/css",l.rel="stylesheet",l.href=e,l.media="screen",n.appendChild(l)}function el(e){return document.getElementById(e)}function elcl(e,n){for(var l,t=(n=n||document).getElementsByTagName("*"),i=-1,a=[];l=t[++i];)-1<(" "+(l.class||l.className)+" ").indexOf(" "+e+" ")&&a.push(l);return a}!function(){addCSS(host+"/css/widget-overlay.css");var e,n=el("tuneefy_overlay"),n=(n?e=n:(e=document.createElement("div")).id="tuneefy_overlay",'
'),l=(e.innerHTML=n+(''),e.style.opacity=1,encodeURIComponent(decodeURIComponent(document.location.href))),t=null,i=null,a="";try{-1!=l.indexOf(".deezer.")&&null!=el("player_track_title")?(i=el("player_track_artist").innerHTML,t=el("player_track_title").innerHTML):-1!=l.indexOf(".deezer.")&&null!=elcl("player-track-title")?(i=elcl("player-track-artist")[0].childNodes[1].innerHTML,t=elcl("player-track-title")[0].firstChild.innerHTML):-1!=l.indexOf(".grooveshark.")&&null!=el("now-playing-metadata")?(i=el("now-playing-metadata").childNodes[4].innerHTML,t=el("now-playing-metadata").firstChild.innerHTML):-1!=l.indexOf(".radionomy.")&&null!=el("track-name")?(i=el("artist-name").innerHTML,t=el("track-name").innerHTML):-1!=l.indexOf(".stereomood.")&&null!=el("info_track_title")?(i=el("info_track_artist").innerHTML,t=el("info_track_title").innerHTML):-1!=l.indexOf(".musicmaze.")&&null!=el("song-title")?(i=el("artist-name").firstChild.innerHTML,t=el("song-title").firstChild.innerHTML):-1!=l.indexOf(".myspace.com/music/player")&&null!=el("mainContent")?(i=el("mainContent").childNodes[3].childNodes[11].childNodes[1].childNodes[5].firstChild.innerHTML,t=el("mainContent").childNodes[3].childNodes[11].childNodes[1].childNodes[3].firstChild.innerHTML):-1!=l.indexOf(".myspace.")&&null!=document.getElementsByTagName("h1")[1]?(i=document.getElementsByTagName("h1")[1].firstChild.innerHTML,t=document.getElementsByTagName("h6")[0].firstChild.firstChild.innerHTML):-1!=l.indexOf("player.qobuz.")&&null!=el("now-playing")?l=encodeURIComponent(el("now-playing").childNodes[2].childNodes[3].childNodes[0].href):-1!=l.indexOf("music.xbox.")&&null!=el("player")?(t=$("#player").find(".playerNowPlaying .playerNowPlayingMetadata .primaryMetadata a").html(),i=$("#player").find(".playerNowPlaying .playerNowPlayingMetadata .secondaryMetadata a:first-child").html()):-1!=l.indexOf("radiooooo.")&&null!=elcl("songinfo--box")[0]&&(i=elcl("song__artist")[0].innerHTML,t=elcl("song__title")[0].innerHTML)}catch(e){t=i=null}null!=i&&void 0!==i&&(a+=i+"+"),null!=t&&void 0!==t&&(a+=t);a=""==a?l:encodeURIComponent(a);e.innerHTML=n+(''),document.body.appendChild(e)}(); -------------------------------------------------------------------------------- /src/Services/ClientCredentialsGrant.php: -------------------------------------------------------------------------------- 1 | clientRepository->getClientEntity($clientId); 28 | 29 | if (!$client) { 30 | error_log("No client with identifier {$clientId} was found"); 31 | 32 | return null; 33 | } 34 | 35 | return $this->issueAccessToken($accessTokenTTL, $client, $client->getIdentifier(), []); 36 | } 37 | 38 | public function respondToAccessTokenRequest( 39 | ServerRequestInterface $request, 40 | ResponseTypeInterface $responseType, 41 | \DateInterval $accessTokenTTL 42 | ): ResponseTypeInterface { 43 | list($clientId) = $this->getClientCredentials($request); 44 | 45 | $client = $this->getClientEntityOrFail($clientId, $request); 46 | 47 | if (!$client->isConfidential()) { 48 | $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); 49 | 50 | throw OAuthServerException::invalidClient($request); 51 | } 52 | 53 | // Validate request 54 | $this->validateClient($request); 55 | 56 | $scopes = $this->validateScopes($this->getRequestParameter('scope', $request, $this->defaultScope)); 57 | 58 | // Finalize the requested scopes 59 | $finalizedScopes = $this->scopeRepository->finalizeScopes($scopes, $this->getIdentifier(), $client); 60 | 61 | // Issue and persist access token 62 | // [CHANGED]: we use the client identifier as user identifier, instead of null 63 | $accessToken = $this->issueAccessToken($accessTokenTTL, $client, $client->getIdentifier(), $finalizedScopes); 64 | 65 | // Send event to emitter 66 | $this->getEmitter()->emit(new RequestAccessTokenEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request, $accessToken)); 67 | 68 | // Inject access token into response type 69 | $responseType->setAccessToken($accessToken); 70 | 71 | return $responseType; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /assets/js/twig/result.html.twig: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 | {%- if type == "track" -%} 4 | 5 | {%- else -%} 6 | 7 | {%- endif -%} 8 |
    9 |
    10 | {% if type == "track" %} 11 |
    12 |
    {{ item.safe_title }} 13 |
    14 | {%- if item.extra_info.is_cover or item.album.extra_info.is_cover -%} 15 | [Cover]  16 | {%- endif -%} 17 | {%- if item.extra_info.is_remix -%} 18 | [Remix]  19 | {%- endif -%} 20 | {%- if item.extra_info.acoustic -%} 21 | [Acoustic]  22 | {%- endif -%} 23 |
    24 |
    25 |
    26 |
    27 |
    {{ item.album.artist }}
    28 |
    29 |
    30 |
    {{ item.album.safe_title }}
    31 |
    32 | {%- else -%} 33 |
    34 |
    {{ item.safe_title }} 35 |
    36 | {%- if item.extra_info.is_cover -%} 37 | [Cover]  38 | {%- endif -%} 39 | {%- if item.extra_info.is_remix -%} 40 | [Remix]  41 | {%- endif -%} 42 | {%- if item.extra_info.acoustic -%} 43 | [Acoustic]  44 | {%- endif -%} 45 |
    46 |
    47 |
    48 |
    49 |
    {{ item.artist }}
    50 |
    51 | {%- endif -%} 52 | 65 | {{ share }} 66 |
  • -------------------------------------------------------------------------------- /public/build/js/twig/result.html.twig: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 | {%- if type == "track" -%} 4 | 5 | {%- else -%} 6 | 7 | {%- endif -%} 8 |
    9 |
    10 | {%- if type == "track" -%} 11 |
    12 |
    {{ item.safe_title }} 13 |
    14 | {%- if item.extra_info.is_cover or item.album.extra_info.is_cover -%} 15 | [Cover]  16 | {%- endif -%} 17 | {%- if item.extra_info.is_remix -%} 18 | [Remix]  19 | {%- endif -%} 20 | {%- if item.extra_info.acoustic -%} 21 | [Acoustic]  22 | {%- endif -%} 23 |
    24 |
    25 |
    26 |
    27 |
    {{ item.album.artist }}
    28 |
    29 |
    30 |
    {{ item.album.safe_title }}
    31 |
    32 | {%- else -%} 33 |
    34 |
    {{ item.safe_title }} 35 |
    36 | {%- if item.extra_info.is_cover -%} 37 | [Cover]  38 | {%- endif -%} 39 | {%- if item.extra_info.is_remix -%} 40 | [Remix]  41 | {%- endif -%} 42 | {%- if item.extra_info.acoustic -%} 43 | [Acoustic]  44 | {%- endif -%} 45 |
    46 |
    47 |
    48 |
    49 |
    {{ item.artist }}
    50 |
    51 | {%- endif -%} 52 | 65 | {{ share }} 66 |
  • -------------------------------------------------------------------------------- /assets/scss/partials/embed.scss: -------------------------------------------------------------------------------- 1 | /* OVER RIDE */ 2 | body {font-size: 0.7em;} 3 | #footer-wrapper, #header-wrapper {display: none;} 4 | .wrap {width: auto; margin: 0;} 5 | .wrap.bdBot, #trackInfo.bdBot{border: none;} 6 | .Bcolor{background: transparent;} 7 | 8 | #wrapper { 9 | background: #151515; 10 | border-radius: 3px; 11 | position: relative; 12 | box-shadow: 0 1px #070707 inset, 0 1px #333333; 13 | } 14 | 15 | /* TAG LINE */ 16 | #tagline{ 17 | height: 36px; 18 | padding: 0; 19 | } 20 | 21 | h1.logo{ 22 | display: inline-block; 23 | margin-left: -6px; 24 | margin-top: -6px; 25 | & > img { 26 | width: 100px; 27 | height: auto 28 | } 29 | } 30 | 31 | p.tagline{ 32 | float: right; 33 | padding-top: 12px; 34 | padding-right: 10px; 35 | font-size: 1em; 36 | color: #696969; 37 | } 38 | 39 | #content { 40 | padding: 0; 41 | } 42 | 43 | /* SHARE */ 44 | #mainTitle{ 45 | font-size: 1.7em; 46 | text-align: center; 47 | padding-top: 50px; 48 | } 49 | 50 | #mainContent{ 51 | width: 100%; 52 | margin: 0; 53 | } 54 | 55 | #trackInfo{ 56 | height: 90px; 57 | padding: 8px; 58 | } 59 | 60 | #trackInfo .cover{ 61 | float: left; 62 | position: relative; 63 | margin: 0 8px 0 0; 64 | } 65 | 66 | #trackInfo .coverlay{ 67 | width: 80px; 68 | height: 80px; 69 | } 70 | 71 | #trackInfo .info{ 72 | margin: 0; 73 | } 74 | 75 | #trackInfo .infoWrapper{ 76 | 77 | } 78 | 79 | #trackInfo .infoContent{ 80 | vertical-align: middle; 81 | } 82 | 83 | #trackInfo .title{ 84 | font-weight: bold; 85 | font-size:1.5em; 86 | line-height: 0.9em; 87 | color: #A2A2A2; 88 | padding-top: 4px; 89 | } 90 | 91 | #trackInfo .artist{ 92 | font-size: 1.2em; 93 | line-height: 1.3em; 94 | color: #474747; 95 | padding-top: 0; 96 | } 97 | 98 | #trackInfo .album{ 99 | font-size: 0.75em; 100 | color: #474747; 101 | text-transform: uppercase; 102 | } 103 | 104 | #platforms, #share{ 105 | padding: 0px; 106 | } 107 | #platforms{ 108 | padding-top: 1px; 109 | } 110 | 111 | #platforms .listenTitle, #share .mainTitle{ 112 | text-transform: uppercase; 113 | font-size: 0.75em; 114 | color: #AAAAAA; 115 | margin-bottom: 5px; 116 | } 117 | 118 | #platforms .btns_full{ 119 | margin-right: 3px; 120 | margin-bottom: 0px; 121 | } 122 | 123 | #linkHolder, #externalPlatformsActions{ 124 | background: #1F1F1F; 125 | float: left; 126 | border: 1px solid black; 127 | padding: 8px; 128 | position: relative; 129 | border-radius: 3px; 130 | } 131 | #linkHolder{ 132 | margin-right: 10px; 133 | } 134 | 135 | .newWindow{ 136 | opacity: 0.9; 137 | margin-left: 3px; 138 | } 139 | 140 | @media (max-width: 500px) { 141 | h1.logo { 142 | width: auto; 143 | } 144 | #tagline { 145 | text-align: left; 146 | } 147 | #tagline p { 148 | float: right; 149 | padding-top: 12px; 150 | padding-right: 10px; 151 | } 152 | #trackInfo { 153 | height: auto; 154 | overflow: auto; 155 | } 156 | #trackInfo .info { 157 | width: auto; 158 | } 159 | } 160 | 161 | @media (max-width: 310px) { 162 | #tagline { 163 | height: auto; 164 | } 165 | #tagline p { 166 | display: block; 167 | padding: 0; 168 | float: none; 169 | margin: 0 0 8px 12px; 170 | } 171 | } -------------------------------------------------------------------------------- /migrations/Version20240401205417.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE oauth2_access_token (identifier CHAR(80) NOT NULL, client VARCHAR(32) NOT NULL, expiry DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', user_identifier VARCHAR(128) DEFAULT NULL, scopes TEXT DEFAULT NULL COMMENT \'(DC2Type:oauth2_scope)\', revoked TINYINT(1) NOT NULL, INDEX IDX_454D9673C7440455 (client), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 20 | $this->addSql('CREATE TABLE oauth2_authorization_code (identifier CHAR(80) NOT NULL, client VARCHAR(32) NOT NULL, expiry DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', user_identifier VARCHAR(128) DEFAULT NULL, scopes TEXT DEFAULT NULL COMMENT \'(DC2Type:oauth2_scope)\', revoked TINYINT(1) NOT NULL, INDEX IDX_509FEF5FC7440455 (client), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 21 | $this->addSql('CREATE TABLE oauth2_client (identifier VARCHAR(32) NOT NULL, name VARCHAR(128) NOT NULL, secret VARCHAR(128) DEFAULT NULL, redirect_uris TEXT DEFAULT NULL COMMENT \'(DC2Type:oauth2_redirect_uri)\', grants TEXT DEFAULT NULL COMMENT \'(DC2Type:oauth2_grant)\', scopes TEXT DEFAULT NULL COMMENT \'(DC2Type:oauth2_scope)\', active TINYINT(1) NOT NULL, allow_plain_text_pkce TINYINT(1) DEFAULT 0 NOT NULL, PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 22 | $this->addSql('CREATE TABLE oauth2_refresh_token (identifier CHAR(80) NOT NULL, access_token CHAR(80) DEFAULT NULL, expiry DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', revoked TINYINT(1) NOT NULL, INDEX IDX_4DD90732B6A2DD68 (access_token), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 23 | $this->addSql('ALTER TABLE oauth2_access_token ADD CONSTRAINT FK_454D9673C7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE'); 24 | $this->addSql('ALTER TABLE oauth2_authorization_code ADD CONSTRAINT FK_509FEF5FC7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE'); 25 | $this->addSql('ALTER TABLE oauth2_refresh_token ADD CONSTRAINT FK_4DD90732B6A2DD68 FOREIGN KEY (access_token) REFERENCES oauth2_access_token (identifier) ON DELETE SET NULL'); 26 | } 27 | 28 | public function down(Schema $schema): void 29 | { 30 | $this->addSql('ALTER TABLE oauth2_access_token DROP FOREIGN KEY FK_454D9673C7440455'); 31 | $this->addSql('ALTER TABLE oauth2_authorization_code DROP FOREIGN KEY FK_509FEF5FC7440455'); 32 | $this->addSql('ALTER TABLE oauth2_refresh_token DROP FOREIGN KEY FK_4DD90732B6A2DD68'); 33 | $this->addSql('DROP TABLE oauth2_access_token'); 34 | $this->addSql('DROP TABLE oauth2_authorization_code'); 35 | $this->addSql('DROP TABLE oauth2_client'); 36 | $this->addSql('DROP TABLE oauth2_refresh_token'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Entity/ApiClient.php: -------------------------------------------------------------------------------- 1 | identifier) 34 | #[ORM\Column(type: 'string', length: 80)] 35 | private $oauth2ClientIdentifier; 36 | 37 | /** 38 | * @return (Role|string)[] The user roles 39 | */ 40 | public function getRoles(): array 41 | { 42 | // Only used in stateful version, when we bypass the oauth 43 | return ['ROLE_BYPASS_AUTH_API']; 44 | } 45 | 46 | /** 47 | * Removes sensitive data from the user. 48 | * 49 | * This is important if, at any given point, sensitive information like 50 | * the plain-text password is stored on this object. 51 | */ 52 | public function eraseCredentials(): void 53 | { 54 | } 55 | 56 | public function getUserIdentifier(): string 57 | { 58 | return $this->name; 59 | } 60 | 61 | public function getName(): string 62 | { 63 | return $this->name; 64 | } 65 | 66 | public function setName(string $name): self 67 | { 68 | $this->name = $name; 69 | 70 | return $this; 71 | } 72 | 73 | public function getEmail(): string 74 | { 75 | return $this->email; 76 | } 77 | 78 | public function setEmail(string $email): self 79 | { 80 | $this->email = $email; 81 | 82 | return $this; 83 | } 84 | 85 | public function getUrl(): ?string 86 | { 87 | return $this->url; 88 | } 89 | 90 | public function setUrl(?string $url): self 91 | { 92 | $this->url = $url; 93 | 94 | return $this; 95 | } 96 | 97 | public function getOauth2ClientIdentifier(): string 98 | { 99 | return $this->oauth2ClientIdentifier; 100 | } 101 | 102 | public function setOauth2ClientIdentifier(string $oauth2ClientIdentifier): self 103 | { 104 | $this->oauth2ClientIdentifier = $oauth2ClientIdentifier; 105 | 106 | return $this; 107 | } 108 | 109 | public function getDescription(): ?string 110 | { 111 | return $this->description; 112 | } 113 | 114 | public function setDescription(?string $description): self 115 | { 116 | $this->description = $description; 117 | 118 | return $this; 119 | } 120 | 121 | public function getCreatedAt(): \DateTime 122 | { 123 | return $this->createdAt; 124 | } 125 | 126 | public function setCreatedAt(\DateTime $createdAt): self 127 | { 128 | $this->createdAt = clone $createdAt; 129 | 130 | return $this; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/EventSubscriber/ApiBypassSubscriber.php: -------------------------------------------------------------------------------- 1 | isMainRequest()) { 28 | // don't do anything if it's not the main request 29 | return; 30 | } 31 | 32 | $request = $event->getRequest(); 33 | 34 | if ($request->query->has('access_token')) { 35 | // We have a token in the query, pass it down in the headers 36 | $request->headers->set('Authorization', 'Bearer '.$request->query->get('access_token')); 37 | 38 | return; 39 | } 40 | 41 | if (!$request->hasSession()) { 42 | // don't do anything if no session 43 | return; 44 | } 45 | 46 | if (!str_starts_with($request->getRequestUri(), '/api/v2/')) { 47 | // Only api requests 48 | return; 49 | } 50 | 51 | if (!$request->isXmlHttpRequest()) { 52 | return; 53 | } 54 | 55 | $session = $request->getSession(); 56 | $shouldBypass = $session->get('shouldBypass', false); 57 | 58 | if (!$shouldBypass) { 59 | return; 60 | } 61 | 62 | if (!$this->bypassClientIdentifier) { 63 | error_log('Bypass client identifier is not set'); 64 | 65 | return; 66 | } 67 | 68 | // Construct the ClientCredentials grant server 69 | $this->server->enableGrantType($this->grant); 70 | 71 | // Request a new token (valid only 2 minutes) 72 | $this->generatedToken = $this->grant->getAccessTokenForClient(new \DateInterval('PT2M'), $this->bypassClientIdentifier); 73 | 74 | if (null === $this->generatedToken) { 75 | return; 76 | } 77 | 78 | // Add the token to the request 79 | $request->headers->set('Authorization', 'Bearer '.$this->generatedToken->__toString()); 80 | } 81 | 82 | public function onTerminate(TerminateEvent $event): void 83 | { 84 | if (!$this->generatedToken) { 85 | return; 86 | } 87 | 88 | $this->repository->revokeAccessToken($this->generatedToken->getIdentifier()); 89 | } 90 | 91 | public static function getSubscribedEvents(): array 92 | { 93 | return [ 94 | // Just after the session listener: 95 | // Symfony\Component\HttpKernel\EventListener\SessionListener 96 | // which has a priority of 128 97 | KernelEvents::REQUEST => ['onRequest', 100], 98 | KernelEvents::TERMINATE => 'onTerminate', 99 | ]; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /templates/about.html.twig: -------------------------------------------------------------------------------- 1 | {% set schemaType, page = "WebPage", "about" %} 2 | 3 | {% extends 'base.html.twig' %} 4 | 5 | {% block content %} 6 |
    7 |

    {{ "pages.about.title_long"|trans|raw }}

    8 | 9 |
    10 |
    {{ "facts.info"|trans|raw }}
    11 |
    {{ "facts.friends"|trans|raw }}
    12 |
    13 | 14 |
    15 |
    {{ "facts.pertinence"|trans|raw }}
    16 |
    {{ "facts.minify"|trans({"%minified%": "javascript:(function(){tb=document.createElement('SCRIPT');a.type='text/javascript';a.src='" ~ asset("/build/js/widget.js") ~ "?x='+(Math.random());document.getElementsByTagName('head')[0].appendChild(a);})();"})|raw }}
    17 |
    18 | 19 |
    20 | 22 |
    23 | {{ "facts.free"|trans|raw }} 24 |
    25 |
    26 | 27 |
    28 | 29 |
    30 |

    {{ "os.title_long"|trans|raw }}

    31 |
    32 |

    {{ "os.paragraph_1_important"|trans|raw }}

    33 |
    {{ "os.paragraph_1"|trans|raw }}
    34 |
    {{ "os.paragraph_2_thanks"|trans|raw }}
    35 |
    36 |
    37 | 38 |
    39 | 40 |
    41 |

    {{ "pages.about.team"|trans|raw }}

    42 |
    {{ "facts.team_idea"|trans|raw }}
    43 |
    {{ "facts.team_design"|trans|raw }}
    44 |
    45 |
    46 | 47 |

    {{ "contact_us.title"|trans|raw }}

    48 |
    49 |
    50 | 51 | 52 | 53 | {{ "contact_us.send"|trans|raw }} 54 |
    55 |
    {{ "mail.sending"|trans|raw }}
    56 |
    {{ "mail.success"|trans|raw }}
    57 |
    {{ "mail.error"|trans|raw }}
    58 |
    59 |
    60 |
    61 | {% endblock %} 62 | 63 | {% block javascript %} 64 | {{ parent() }} 65 | 66 | 67 | 74 | {% endblock %} 75 | -------------------------------------------------------------------------------- /src/Security/LoginFormAuthenticator.php: -------------------------------------------------------------------------------- 1 | urlGenerator = $urlGenerator; 33 | $this->csrfTokenManager = $csrfTokenManager; 34 | $this->adminLogin = $adminLogin; 35 | $this->adminPassword = $adminPassword; 36 | } 37 | 38 | protected function getLoginUrl(Request $request): string 39 | { 40 | return $this->urlGenerator->generate('admin_login'); 41 | } 42 | 43 | public function supports(Request $request): bool 44 | { 45 | return 'admin_login' === $request->attributes->get('_route') 46 | && $request->isMethod('POST'); 47 | } 48 | 49 | public function authenticate(Request $request): Passport 50 | { 51 | $credentials = [ 52 | 'username' => $request->request->get('_username'), 53 | 'password' => $request->request->get('_password'), 54 | 'csrf_token' => $request->request->get('_csrf_token'), 55 | ]; 56 | $request->getSession()->set( 57 | SecurityRequestAttributes::LAST_USERNAME, 58 | $credentials['username'] 59 | ); 60 | 61 | if ($credentials['username'] !== $this->adminLogin) { 62 | // fail authentication with a custom error 63 | throw new CustomUserMessageAuthenticationException('Username could not be found.'); 64 | } 65 | 66 | if ($credentials['password'] !== $this->adminPassword) { 67 | // fail authentication with a custom error 68 | throw new CustomUserMessageAuthenticationException('Invalid credentials.'); 69 | } 70 | 71 | return new SelfValidatingPassport( 72 | new UserBadge($this->adminLogin), 73 | [new CsrfTokenBadge('authenticate', $credentials['csrf_token'])] 74 | ); 75 | } 76 | 77 | public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey): ?Response 78 | { 79 | if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) { 80 | return new RedirectResponse($targetPath); 81 | } 82 | 83 | return new RedirectResponse($this->urlGenerator->generate('admin_dashboard')); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /templates/admin/clients.new.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'admin/_base.html.twig' %} 2 | {% set menu = "clients" %} 3 | 4 | {% block content %} 5 |

    API Clients

    6 | 7 |

    Create a new client

    8 | 9 | {% if not form.vars.valid %} 10 |
    11 | 12 |
    13 | Error 14 |
    15 | {{ form_errors(form) }} 16 |
    17 | {% endif %} 18 | 19 | {{ form_start(form, {'attr': {'class': 'ui form'}}) }} 20 |
    21 |
    22 | 23 | {{ form_widget(form.name, {'attr': {'placeholder': 'API User'}}) }} 24 |
    25 |
    26 |
    27 | {{ form_widget(form.active) }} 28 | 29 |
    30 |
    31 |
    32 |
    33 |
    34 | 35 | {{ form_widget(form.email, {'attr': {'placeholder': 'user@example.org'}}) }} 36 |
    37 |
    38 | 39 | {{ form_widget(form.url, {'attr': {'placeholder': 'http://site.com'}}) }} 40 |
    41 |
    42 |
    43 |
    44 | 45 |
    46 | {{ form_widget(form.identifier) }} 47 | 48 |
    49 |
    50 |
    51 | 52 |
    53 | {{ form_widget(form.secret) }} 54 | 55 |
    56 |
    57 |
    58 |
    59 | 60 | {{ form_widget(form.description) }} 61 |
    62 | Back 63 | 64 | {{ form_end(form) }} 65 | 66 | 67 | {% endblock %} 68 | 69 | {% block javascript %} 70 | {{ parent() }} 71 | 106 | {% endblock %} 107 | -------------------------------------------------------------------------------- /templates/trends.html.twig: -------------------------------------------------------------------------------- 1 | {% set schemaType, page = "WebPage", "trends" %} 2 | 3 | {% extends 'base.html.twig' %} 4 | 5 | {% block content %} 6 |
    7 |

    {{ "pages.trends.title_long"|trans|raw }}

    8 | 9 |
    10 |

    {{ "stats.global"|trans|raw }}

    11 | 12 | {% for hit in stats.hits|filter(hit => hit.platform.tag is defined) %} 13 | 18 | 19 | {% endfor %} 20 | 21 | 22 |
    23 | 24 |
      25 | {% for hit in stats.hits|filter(hit => hit.platform.tag is defined) %} 26 |
    • {{ hit.platform.name }}{{ (hit.count / total * 100)|number_format(1) }}%
    • 27 | {% endfor %} 28 |
    29 |
    30 | 31 | {% if stats.artists|length > 0 %} 32 |
    33 |

    {{ "stats.most_viewed_artists"|trans({"%limit%": most_viewed_stats_limit})|raw }}

    34 |
      35 | {% set base = (1 / stats.artists[0].count) * 170 %} 36 | {% for data in stats.artists %} 37 |
    • {{ data.artist }} 38 |
      {{ "stats.views"|trans({"%val%": data.count})|raw }} 39 |
    • 40 | {% endfor %} 41 |
    42 |
    43 | {% endif %} 44 | 45 | {% if stats.tracks|length > 0 %} 46 |
    47 |

    {{ "stats.most_viewed_tracks"|trans({"%limit%": most_viewed_stats_limit})|raw }}

    48 |
      49 | {% for key, data in stats.tracks %} 50 | {% set fontSize = 2.3*(1-loop.index/5) + (loop.index/5)*0.6 %} 51 |
    • {{ "stats.views"|trans({"%val%": data.count})|raw }} 52 | {{ loop.index }}{{ data.track }}{{ data.artist }} 53 |
    • 54 | {% endfor %} 55 |
    56 |
    57 | {% endif %} 58 | 59 | {% if stats.albums|length > 0 %} 60 |
    61 |

    {{ "stats.most_viewed_albums"|trans({"%limit%": most_viewed_stats_limit})|raw }}

    62 |
      63 | {% for key, data in stats.albums %} 64 | {% set fontSize = 2.3*(1-loop.index/5) + (loop.index/5)*0.6 %} 65 |
    • {{ "stats.views"|trans({"%val%": data.count})|raw }} 66 | {{ loop.index }}{{ data.album }}{{ data.artist }} 67 |
    • 68 | {% endfor %} 69 |
    70 |
    71 | {% endif %} 72 |
    73 | {% endblock %} 74 | 75 | 76 | {% block javascript %} 77 | {{ parent() }} 78 | 79 | 80 | {% endblock %} 81 | -------------------------------------------------------------------------------- /public/build/css/widget.css: -------------------------------------------------------------------------------- 1 | html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:"";content:none}table{border-collapse:collapse;border-spacing:0}a,a:link{text-decoration:none}input,textarea{outline:none}body{font:normal normal normal 1em/1em arial,helvetica,sans-serif}a.btn{position:relative;color:#fff;text-transform:lowercase;font-size:14px;font-weight:bold;display:block;background:#222;background-image:-moz-linear-gradient(center top, #262626 0%, #1B1B1B 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #262626), color-stop(1, #1B1B1B));border:1px solid #000;border-radius:3px;padding:6px 15px 7px 15px;cursor:pointer;text-shadow:0 -2px #000;box-shadow:0 1px #333 inset,0 1px #171717}a.btn:hover{color:#76bad0}a.btn:active{box-shadow:0 3px 3px #171717 inset,0 1px #171717}a.btns{display:inline-block;width:36px;height:31px;cursor:pointer}a.btns:hover{background-position:0px -32px}a.btns.off{background-position:0px -64px}a.btn_deezer{background:url("../img/platforms/platform_deezer.png") no-repeat}a.btn_spotify{background:url("../img/platforms/platform_spotify.png") no-repeat}a.btn_qobuz{background:url("../img/platforms/platform_qobuz.png") no-repeat}a.btn_lastfm{background:url("../img/platforms/platform_lastfm.png") no-repeat}a.btn_itunes{background:url("../img/platforms/platform_itunes.png") no-repeat}a.btn_youtube{background:url("../img/platforms/platform_youtube.png") no-repeat}a.btn_soundcloud{background:url("../img/platforms/platform_soundcloud.png") no-repeat}a.btn_mixcloud{background:url("../img/platforms/platform_mixcloud.png") no-repeat}a.btn_groove{background:url("../img/platforms/platform_groove.png") no-repeat}a.btn_napster{background:url("../img/platforms/platform_napster.png") no-repeat}a.btn_tidal{background:url("../img/platforms/platform_tidal.png") no-repeat}a.btn_amazon{background:url("../img/platforms/platform_amazon.png") no-repeat}a.btn_hypem{background:url("../img/platforms/platform_hypem.png") no-repeat}.txtS{text-shadow:0 -1px #000 !important}ul li.tHeader{display:none}#waiting{background:#1a1a1a;height:100%;left:0;opacity:.8;position:absolute;top:0;width:100%;z-index:10}#waiting img{position:absolute;left:200px;top:66px}#results{padding:16px}.nbResults{color:#76bad0;font-size:18px;text-shadow:0 -1px #000;margin-bottom:10px}li.tResult{box-shadow:0 1px #070707 inset,0 1px #333;border-radius:3px;background:#151515;height:60px;padding:10px}li.tResult .coverlay{position:absolute;top:4px;left:4px;width:48px;height:48px;border:1px solid #aaa;background:url("../img/coverlay.png") center center}li.tResult .tImage{float:left;position:relative;margin-right:20px}li.tResult .tImage img{width:50px;height:50px;border:4px solid #fff;box-shadow:0px 0px 3px #000;display:block}li.tResult .tTitle{font-weight:bold;font-size:16px;color:#a2a2a2;padding-top:4px}li.tResult .tFeat{display:none}li.tResult .tArtist{color:#474747;font-size:12px;padding-top:4px}li.tResult .tAlbum{color:#474747;font-size:10px;padding-top:2px;text-transform:uppercase}li.tResult .tShare{position:absolute;bottom:14px;right:138px}.tLinks{position:absolute;bottom:11px;left:14px}.tLinks .wrapper{height:33px;overflow:hidden;width:205px}#more{position:absolute;bottom:14px;right:16px}#alerts{height:100%;width:100%;text-align:center;padding-top:66px}#alerts span{text-shadow:0 -1px #000 !important;color:#76bad0}a.backToTop{display:none !important} -------------------------------------------------------------------------------- /assets/scss/partials/iphone-style.scss: -------------------------------------------------------------------------------- 1 | .iPhoneCheckContainer, 2 | .iPhoneCheckContainer2 { 3 | position: relative; 4 | height: 27px; 5 | cursor: pointer; 6 | overflow: hidden; } 7 | .iPhoneCheckContainer input, 8 | .iPhoneCheckContainer2 input { 9 | position: absolute; 10 | top: 5px; 11 | left: 30px; 12 | filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=0); 13 | opacity: 0; } 14 | .iPhoneCheckContainer label, 15 | .iPhoneCheckContainer2 label { 16 | white-space: nowrap; 17 | font-size: 17px; 18 | line-height: 17px; 19 | font-weight: bold; 20 | font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; 21 | cursor: pointer; 22 | display: block; 23 | height: 27px; 24 | position: absolute; 25 | width: auto; 26 | top: 0; 27 | padding-top: 5px; 28 | overflow: hidden; } 29 | .iPhoneCheckContainer, .iPhoneCheckContainer2, .iPhoneCheckContainer label, .iPhoneCheckContainer2 label { 30 | user-select: none; 31 | -moz-user-select: none; 32 | -khtml-user-select: none; } 33 | 34 | .iPhoneCheckDisabled { 35 | filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=50); 36 | opacity: 0.5; } 37 | 38 | label.iPhoneCheckLabelOn, 39 | label.iPhoneCheckLabelOn2 40 | { 41 | color: white; 42 | background: url('../../img/iphone-style/on.png') no-repeat; 43 | text-shadow: 0px 0px 2px rgba(0, 0, 0, 0.6); 44 | left: 0; 45 | padding-top: 5px; } 46 | label.iPhoneCheckLabelOn span { 47 | padding-left: 8px; } 48 | label.iPhoneCheckLabelOff, 49 | label.iPhoneCheckLabelOff2 { 50 | color: #8b8b8b; 51 | background: url('../../img/iphone-style/off.png') no-repeat right 0; 52 | text-shadow: 0px 0px 2px rgba(255, 255, 255, 0.6); 53 | text-align: right; 54 | right: 0; } 55 | label.iPhoneCheckLabelOff span { 56 | padding-right: 8px; } 57 | 58 | .iPhoneCheckHandle, 59 | .iPhoneCheckHandle2 { 60 | display: block; 61 | height: 27px; 62 | cursor: pointer; 63 | position: absolute; 64 | top: 0; 65 | left: 0; 66 | width: 0; 67 | background: url('../../img/iphone-style/slider_left.png') no-repeat; 68 | padding-left: 3px; } 69 | 70 | .iPhoneCheckHandle2{ 71 | background: blue; 72 | border-radius: 3px; 73 | } 74 | 75 | .iPhoneCheckHandleRight, 76 | .iPhoneCheckHandleRight2 { 77 | height: 100%; 78 | width: 100%; 79 | padding-right: 3px; 80 | background: url('../../img/iphone-style/slider_right.png') no-repeat right 0; } 81 | 82 | .iPhoneCheckHandleCenter, 83 | .iPhoneCheckHandleCenter2 { 84 | height: 100%; 85 | width: 100%; 86 | background: url('../../img/iphone-style/slider_center.png'); } 87 | 88 | /* tchap : for CSS */ 89 | .iPhoneCheckContainer { width: 92px; } 90 | .iPhoneCheckHandle { width: 40px; } 91 | 92 | /* Tracks and albums checkbox */ 93 | .noBG { 94 | background: none !important; 95 | } 96 | 97 | 98 | .otherContainer { 99 | border: 1px solid black; 100 | margin: 0 10px; 101 | background: #282828 !important; 102 | border-radius: 4px; 103 | box-shadow: 0 0 5px black inset, 0 1px #282828; 104 | } 105 | 106 | .otherHandle { 107 | width: 42px; 108 | top: -1px; 109 | border: 1px solid black; 110 | background: #222; 111 | background-image: -moz-linear-gradient(center top , #333333 0%, #181818 100%); 112 | background-image: -webkit-gradient(linear, left top,left bottom, color-stop(0,#333333), color-stop(1,#181818)); 113 | border-radius: 4px; 114 | margin-left: -1px; 115 | } 116 | 117 | .activeTracks{ 118 | background: url('../../img/track_on.png') no-repeat 9px 3px; 119 | } 120 | .activeAlbums{ 121 | background: url('../../img/album_on.png') no-repeat 9px 3px; 122 | } 123 | 124 | .otherContainer label { 125 | height: 22px; 126 | } 127 | 128 | label.iPhoneCheckLabelOn.albums{ 129 | background: url('../../img/album_off.png') no-repeat 13px 4px; 130 | } 131 | label.iPhoneCheckLabelOff.tracks{ 132 | background: url('../../img/track_off.png') no-repeat 54px 3px; 133 | } -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | # Used for routing the API pages correctly 3 | website.host: "%env(WEBSITE_HOST)%" 4 | api.host: "%env(API_HOST)%" 5 | 6 | uid_base: 11111 7 | mail.captcha_key: "%env(CAPTCHA_KEY)%" 8 | mail.captcha_secret: "%env(CAPTCHA_SECRET)%" 9 | mail.contact_email: "contact@tuneefy.com" 10 | mail.team_email: "tchap@tuneefy.com" 11 | api.use_oauth: true 12 | api.bypass_client_identifier: "%env(API_BYPASS_CLIENT_IDENTIFIER)%" 13 | intents.lifetime: 600 # seconds 14 | intents.secret: "%env(INTENTS_SECRET)%" 15 | 16 | services: 17 | # default configuration for services in *this* file 18 | _defaults: 19 | autowire: true # Automatically injects dependencies in your services. 20 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 21 | 22 | # makes classes in src/ available to be used as services 23 | # this creates a service per class whose id is the fully-qualified class name 24 | App\: 25 | resource: '../src/' 26 | exclude: 27 | - '../src/DependencyInjection/' 28 | - '../src/Entity/' 29 | - '../src/Kernel.php' 30 | 31 | App\Security\LoginFormAuthenticator: 32 | arguments: 33 | $adminLogin: "%env(ADMIN_LOGIN)%" 34 | $adminPassword: "%env(ADMIN_PASSWORD)%" 35 | 36 | App\Utils\Utils: 37 | arguments: 38 | $base: "%uid_base%" 39 | 40 | App\EventSubscriber\ApiBypassSubscriber: 41 | arguments: 42 | $bypassClientIdentifier: "%api.bypass_client_identifier%" 43 | 44 | # Overriding this class to be able to properly get the client id in the token 45 | # and create tokens on the fly 46 | League\OAuth2\Server\Grant\ClientCredentialsGrant: 47 | class: App\Services\ClientCredentialsGrant 48 | 49 | # All platforms 50 | App\Services\Platforms\DeezerPlatform: 51 | arguments: 52 | $key: "%env(DEEZER_KEY)%" 53 | $secret: "%env(DEEZER_SECRET)%" 54 | tags: ['app.platform'] 55 | 56 | App\Services\Platforms\SpotifyPlatform: 57 | arguments: 58 | $key: "%env(SPOTIFY_KEY)%" 59 | $secret: "%env(SPOTIFY_SECRET)%" 60 | tags: ['app.platform'] 61 | 62 | App\Services\Platforms\QobuzPlatform: 63 | arguments: 64 | $key: "%env(QOBUZ_KEY)%" 65 | $secret: "%env(QOBUZ_SECRET)%" 66 | tags: ['app.platform'] 67 | 68 | App\Services\Platforms\LastFMPlatform: 69 | arguments: 70 | $key: "%env(LASTFM_KEY)%" 71 | $secret: "%env(LASTFM_SECRET)%" 72 | tags: ['app.platform'] 73 | 74 | App\Services\Platforms\SoundcloudPlatform: 75 | arguments: 76 | $key: "%env(SOUNDCLOUD_KEY)%" 77 | $secret: "%env(SOUNDCLOUD_SECRET)%" 78 | tags: ['app.platform'] 79 | 80 | App\Services\Platforms\YoutubePlatform: 81 | arguments: 82 | $key: "%env(YOUTUBE_KEY)%" 83 | $secret: "%env(YOUTUBE_SECRET)%" 84 | tags: ['app.platform'] 85 | 86 | App\Services\Platforms\MixcloudPlatform: 87 | arguments: 88 | $key: "%env(MIXCLOUD_KEY)%" 89 | $secret: "%env(MIXCLOUD_SECRET)%" 90 | tags: ['app.platform'] 91 | 92 | App\Services\Platforms\ItunesPlatform: 93 | arguments: 94 | $key: "%env(ITUNES_KEY)%" 95 | $secret: "%env(ITUNES_SECRET)%" 96 | tags: ['app.platform'] 97 | 98 | App\Services\Platforms\TidalPlatform: 99 | arguments: 100 | $key: "%env(TIDAL_KEY)%" 101 | $secret: "%env(TIDAL_SECRET)%" 102 | tags: ['app.platform'] 103 | 104 | App\Services\Platforms\NapsterPlatform: 105 | arguments: 106 | $key: "%env(NAPSTER_KEY)%" 107 | $secret: "%env(NAPSTER_SECRET)%" 108 | tags: ['app.platform'] 109 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tchapi/tuneefy", 3 | "description": "tuneefy is a unified way to share music with your friends, over various online music services", 4 | "license": "GPL-2.0", 5 | "authors": [ 6 | { 7 | "name": "tchapi", 8 | "email": "tchap@tchap.me", 9 | "homepage": "https://tchap.me" 10 | } 11 | ], 12 | "support": { 13 | "email": "team@tuneefy.com", 14 | "issues": "https://github.com/tchapi/tuneefy2/issues" 15 | }, 16 | "type": "project", 17 | "minimum-stability": "stable", 18 | "prefer-stable": true, 19 | "require": { 20 | "php": ">=8.2", 21 | "ext-ctype": "*", 22 | "ext-iconv": "*", 23 | "doctrine/dbal": "^3", 24 | "doctrine/doctrine-bundle": "^2.11", 25 | "doctrine/doctrine-migrations-bundle": "^3.3", 26 | "doctrine/orm": "^3.1", 27 | "league/oauth2-server-bundle": "^0.9.0", 28 | "nelmio/cors-bundle": "^2.4", 29 | "phpdocumentor/reflection-docblock": "^5.3", 30 | "phpstan/phpdoc-parser": "^1.26", 31 | "symfony/asset": "7.1.*", 32 | "symfony/asset-mapper": "7.1.*", 33 | "symfony/console": "7.1.*", 34 | "symfony/dotenv": "7.1.*", 35 | "symfony/expression-language": "7.1.*", 36 | "symfony/flex": "^2", 37 | "symfony/form": "7.1.*", 38 | "symfony/framework-bundle": "7.1.*", 39 | "symfony/http-client": "7.1.*", 40 | "symfony/intl": "7.1.*", 41 | "symfony/mailer": "7.1.*", 42 | "symfony/mime": "7.1.*", 43 | "symfony/monolog-bundle": "^3.0", 44 | "symfony/property-access": "7.1.*", 45 | "symfony/property-info": "7.1.*", 46 | "symfony/runtime": "7.1.*", 47 | "symfony/security-bundle": "7.1.*", 48 | "symfony/serializer": "7.1.*", 49 | "symfony/string": "7.1.*", 50 | "symfony/translation": "7.1.*", 51 | "symfony/twig-bundle": "7.1.*", 52 | "symfony/validator": "7.1.*", 53 | "symfony/web-link": "7.1.*", 54 | "symfony/yaml": "7.1.*", 55 | "twig/extra-bundle": "^2.12|^3.0", 56 | "twig/twig": "^2.12|^3.0" 57 | }, 58 | "config": { 59 | "allow-plugins": { 60 | "php-http/discovery": true, 61 | "symfony/flex": true, 62 | "symfony/runtime": true 63 | }, 64 | "sort-packages": true 65 | }, 66 | "autoload": { 67 | "psr-4": { 68 | "App\\": "src/" 69 | } 70 | }, 71 | "autoload-dev": { 72 | "psr-4": { 73 | "App\\Tests\\": "tests/" 74 | } 75 | }, 76 | "replace": { 77 | "symfony/polyfill-ctype": "*", 78 | "symfony/polyfill-iconv": "*", 79 | "symfony/polyfill-php80": "*", 80 | "symfony/polyfill-php81": "*", 81 | "symfony/polyfill-php82": "*" 82 | }, 83 | "scripts": { 84 | "auto-scripts": { 85 | "cache:clear": "symfony-cmd", 86 | "assets:install %PUBLIC_DIR%": "symfony-cmd", 87 | "importmap:install": "symfony-cmd" 88 | }, 89 | "post-install-cmd": [ 90 | "@auto-scripts" 91 | ], 92 | "post-update-cmd": [ 93 | "@auto-scripts" 94 | ] 95 | }, 96 | "conflict": { 97 | "symfony/symfony": "*" 98 | }, 99 | "require-dev": { 100 | "codeception/codeception": "^5.1", 101 | "codeception/module-asserts": "^3.0", 102 | "codeception/module-phpbrowser": "^3.0", 103 | "codeception/module-rest": "^3.3", 104 | "friendsofphp/php-cs-fixer": "^3.51", 105 | "symfony/browser-kit": "7.1.*", 106 | "symfony/css-selector": "7.1.*", 107 | "symfony/debug-bundle": "7.1.*", 108 | "symfony/maker-bundle": "^1.0", 109 | "symfony/phpunit-bridge": "7.1.*", 110 | "symfony/stopwatch": "7.1.*", 111 | "symfony/web-profiler-bundle": "7.1.*" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Entity/Item.php: -------------------------------------------------------------------------------- 1 | true])] 20 | private $id; 21 | 22 | #[ORM\Column(type: 'string', length: 170, nullable: true)] 23 | private $intent; 24 | 25 | #[ORM\Column(type: 'blob', nullable: false, length: 65532)] 26 | private $object; 27 | 28 | #[ORM\Column(type: 'string', length: 170, nullable: true)] 29 | private $track; 30 | 31 | #[ORM\Column(type: 'string', length: 170, nullable: true)] 32 | private $album; 33 | 34 | #[ORM\Column(type: 'string', length: 170, nullable: true)] 35 | private $artist; 36 | 37 | #[ORM\Column(type: 'datetime', nullable: false)] 38 | private $createdAt; 39 | 40 | #[ORM\Column(type: 'datetime', nullable: true)] 41 | private $expiresAt; 42 | 43 | #[ORM\Column(type: 'string', length: 170, nullable: false)] 44 | private $signature; 45 | 46 | #[ORM\Column(type: 'string', length: 80, nullable: true)] 47 | private $clientId; 48 | 49 | public function getId(): int 50 | { 51 | return $this->id; 52 | } 53 | 54 | public function setIntent(?string $intent): self 55 | { 56 | $this->intent = $intent; 57 | 58 | return $this; 59 | } 60 | 61 | public function getMusicalEntity(): MusicalEntity|false 62 | { 63 | // Migrating from the previous namespace 64 | // It works because the class names have the same length (with the namespace) 65 | $migratedObject = str_replace( 66 | ['tuneefy\MusicalEntity\Entities\AlbumEntity', 'tuneefy\MusicalEntity\Entities\TrackEntity'], 67 | ['App\Dataclass\MusicalEntity\Entities\Album', 'App\Dataclass\MusicalEntity\Entities\Track'], 68 | $this->getObject() 69 | ); 70 | 71 | $objectAsMusicalEntity = unserialize($migratedObject); 72 | 73 | return $objectAsMusicalEntity; 74 | } 75 | 76 | public function getObject(): string 77 | { 78 | if (is_resource($this->object)) { 79 | $this->object = stream_get_contents($this->object); 80 | } 81 | 82 | return $this->object; 83 | } 84 | 85 | public function setMusicalEntity(MusicalEntity $entity): self 86 | { 87 | $entityAsString = serialize($entity); 88 | $this->object = $entityAsString; 89 | 90 | return $this; 91 | } 92 | 93 | public function setObject(string $object): self 94 | { 95 | $this->object = $object; 96 | 97 | return $this; 98 | } 99 | 100 | public function setTrack(?string $track): self 101 | { 102 | $this->track = $track; 103 | 104 | return $this; 105 | } 106 | 107 | public function setArtist(string $artist): self 108 | { 109 | $this->artist = $artist; 110 | 111 | return $this; 112 | } 113 | 114 | public function setAlbum(string $album): self 115 | { 116 | $this->album = $album; 117 | 118 | return $this; 119 | } 120 | 121 | public function setCreatedAt(\DateTime $createdAt): self 122 | { 123 | $this->createdAt = $createdAt; 124 | 125 | return $this; 126 | } 127 | 128 | public function setExpiresAt(?\DateTime $expiresAt): self 129 | { 130 | $this->expiresAt = $expiresAt; 131 | 132 | return $this; 133 | } 134 | 135 | public function getSignature(): string 136 | { 137 | return $this->signature; 138 | } 139 | 140 | public function setSignature(string $signature): self 141 | { 142 | $this->signature = $signature; 143 | 144 | return $this; 145 | } 146 | 147 | public function setClientId(?string $clientId): self 148 | { 149 | $this->clientId = $clientId; 150 | 151 | return $this; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /deploy/deploy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: "{{ tuneefy_hosts }}" 3 | vars_files: 4 | - ./vars.yml 5 | tasks: 6 | - name: Set some variables 7 | set_fact: 8 | release_path: "{{ project_path }}/releases/{{ lookup('pipe','date +%Y%m%d%H%M%S') }}" 9 | current_path: "{{ project_path }}/current" 10 | shared_path: "{{ project_path }}/shared" 11 | - name: Retrieve current release folder 12 | command: readlink -f current 13 | register: current_release_path 14 | ignore_errors: true 15 | args: 16 | chdir: "{{ project_path }}" 17 | - name: Ensure shared folder is present 18 | file: 19 | path: "{{ shared_path }}" 20 | state: directory 21 | - name: Ensure shared logs folder is present 22 | file: 23 | path: "{{ shared_path }}/logs" 24 | state: directory 25 | - name: Create new folder 26 | file: 27 | dest={{ release_path }} 28 | mode=0755 29 | recurse=yes 30 | state=directory 31 | - name: Clone the repository 32 | git: 33 | repo: "git@github.com:tchapi/tuneefy2.git" 34 | dest: "{{ release_path }}" 35 | - name: Link .env.local file 36 | file: 37 | src={{ shared_path }}/.env.local 38 | dest={{ release_path }}/.env.local 39 | state=link 40 | - name: Link keys files (JWT) for the Oauth2 41 | file: 42 | src={{ shared_path }}/jwt 43 | dest={{ release_path }}/config/jwt 44 | state=link 45 | - name: Create the var and cache/prod folder 46 | file: 47 | dest={{ release_path }}/var/cache/prod 48 | mode=0775 49 | recurse=yes 50 | state=directory 51 | group=www-data 52 | - name: Link var/logs directory 53 | file: 54 | src={{ shared_path }}/logs 55 | dest={{ release_path }}/var/log 56 | state=link 57 | - name: Install composer dependencies 58 | composer: 59 | command: install 60 | working_dir: "{{ release_path }}" 61 | prefer_dist: yes 62 | - name: Remove perilous files 63 | file: 64 | path: "{{ release_path }}/{{ item }}" 65 | state: absent 66 | with_items: 67 | - .gitignore 68 | - .git 69 | - .php-cs-fixer.php 70 | - .editorconfig 71 | - deploy 72 | - LICENSE 73 | - README.md 74 | - gulpfile.js 75 | - examples 76 | - package.json 77 | - yarn.lock 78 | - codeception.yml 79 | - codeception.yml.dist 80 | - name: Creates a cron file under /etc/cron.d for updating stats and cleaning intents 81 | become: true 82 | become_user: root 83 | cron: 84 | name: "Tuneefy: Update stats" 85 | minute: "14" 86 | user: "{{ansible_user}}" 87 | job: "/usr/bin/php {{current_path}}/bin/console tuneefy:update-stats >> {{shared_path}}/logs/cron-job.log" 88 | cron_file: tuneefy_update-stats 89 | - name: Creates a cron file under /etc/cron.d for cleaning intents 90 | become: true 91 | become_user: root 92 | cron: 93 | name: "Tuneefy: Clean expired intents" 94 | minute: "18" 95 | user: "{{ansible_user}}" 96 | job: "/usr/bin/php {{current_path}}/bin/console tuneefy:clean-expired-intents >> {{shared_path}}/logs/cron-job.log" 97 | cron_file: tuneefy_clean-expired-intents 98 | - name: Creates a cron file under /etc/cron.d for cleaning expired tokens 99 | become: true 100 | become_user: root 101 | cron: 102 | name: "Tuneefy: Clear expired tokens" 103 | minute: "35" 104 | user: "{{ansible_user}}" 105 | job: "/usr/bin/php {{current_path}}/bin/console league:oauth2-server:clear-expired-tokens >> {{shared_path}}/logs/cron-job.log" 106 | cron_file: tuneefy_clean-expired-tokens 107 | - name: Update symlink 108 | file: 109 | src={{ release_path }} 110 | dest={{ current_path }} 111 | state=link 112 | - name: Restart PHP-FPM 113 | become: true 114 | become_user: root 115 | service: 116 | name: "php8.2-fpm" 117 | state: restarted 118 | - name: Give correct permissions 119 | file: 120 | state: directory 121 | path: "{{ current_path }}/var/cache" 122 | owner: debian 123 | group: www-data 124 | recurse: true 125 | mode: '0775' 126 | -------------------------------------------------------------------------------- /src/Services/Platforms/PlatformResult.php: -------------------------------------------------------------------------------- 1 | musical_entity = $musical_entity; 34 | $this->metadata = $metadata; 35 | $this->intent = uniqid(); // We create it now for later use 36 | $this->expires = null; 37 | } 38 | 39 | public function getMetadata(): array 40 | { 41 | return $this->metadata; 42 | } 43 | 44 | public function getMusicalEntity(): ?MusicalEntity 45 | { 46 | return $this->musical_entity; 47 | } 48 | 49 | public function getIntent(): string 50 | { 51 | return $this->intent; 52 | } 53 | 54 | public function setExpires(\DateTime $expires): PlatformResult 55 | { 56 | $this->expires = $expires; 57 | 58 | return $this; 59 | } 60 | 61 | public function toArray(): array 62 | { 63 | if (null === $this->musical_entity) { 64 | return [ 65 | 'metadata' => $this->metadata, 66 | ]; 67 | } else { 68 | return [ 69 | 'musical_entity' => $this->musical_entity->toArray(), 70 | 'metadata' => $this->metadata, 71 | 'share' => [ 72 | 'intent' => $this->intent, 73 | 'expires' => $this->expires ? $this->expires->format(\DateTime::ATOM) : null, 74 | ], 75 | ]; 76 | } 77 | } 78 | 79 | public function mergeWith(PlatformResult $that): PlatformResult 80 | { 81 | $thatMusicalEntity = $that->getMusicalEntity(); 82 | 83 | if (null !== $this->musical_entity && null !== $thatMusicalEntity) { 84 | // Merge musical entities 85 | if ($this->musical_entity instanceof Track) { 86 | $this->musical_entity = Track::merge($this->musical_entity, $thatMusicalEntity); 87 | } elseif ($this->musical_entity instanceof Album) { 88 | $this->musical_entity = Album::merge($this->musical_entity, $thatMusicalEntity); 89 | } 90 | 91 | // Merge score 92 | $thatMetadata = $that->getMetadata(); 93 | if (array_key_exists('score', $this->metadata) && array_key_exists('score', $thatMetadata)) { 94 | $this->metadata['score'] = floatval($this->metadata['score']) + floatval($thatMetadata['score']); 95 | } 96 | 97 | // Merge other metadata 98 | $this->metadata['externalIds'] = array_merge($this->metadata['externalIds'], $thatMetadata['externalIds']); 99 | 100 | if (array_key_exists('merges', $this->metadata)) { 101 | $this->metadata['merges'] = $this->metadata['merges'] + 1; 102 | } else { 103 | $this->metadata['merges'] = 1; 104 | } 105 | } 106 | 107 | return $this; 108 | } 109 | 110 | public function finalizeMerge(): PlatformResult 111 | { 112 | // Compute a final score 113 | if (array_key_exists('merges', $this->metadata) && array_key_exists('score', $this->metadata)) { 114 | // The more merges, the better the result must be 115 | $merge_quantifier_offset = floatval($this->metadata['merges']) / 2.25; // Completely heuristic number 116 | $this->metadata['score'] = $merge_quantifier_offset + floatval($this->metadata['score']) / (floatval($this->metadata['merges']) + 1); 117 | } elseif (array_key_exists('score', $this->metadata)) { 118 | // has not been merged, ever. Lower score 119 | $this->metadata['score'] = floatval($this->metadata['score']) / 2; 120 | } else { 121 | $this->metadata['score'] = 0.0; 122 | } 123 | 124 | $this->metadata['score'] = round($this->metadata['score'], 3); 125 | 126 | return $this; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /assets/js/vendor/tablesort.js: -------------------------------------------------------------------------------- 1 | /* 2 | A simple, lightweight jQuery plugin for creating sortable tables. 3 | https://github.com/kylefox/jquery-tablesort 4 | Version 0.0.11 5 | */ 6 | 7 | (function($) { 8 | $.tablesort = function ($table, settings) { 9 | var self = this; 10 | this.$table = $table; 11 | this.$thead = this.$table.find('thead'); 12 | this.settings = $.extend({}, $.tablesort.defaults, settings); 13 | this.$sortCells = this.$thead.length > 0 ? this.$thead.find('th:not(.no-sort)') : this.$table.find('th:not(.no-sort)'); 14 | this.$sortCells.on('click.tablesort', function() { 15 | self.sort($(this)); 16 | }); 17 | this.index = null; 18 | this.$th = null; 19 | this.direction = null; 20 | }; 21 | 22 | $.tablesort.prototype = { 23 | 24 | sort: function(th, direction) { 25 | var start = new Date(), 26 | self = this, 27 | table = this.$table, 28 | rowsContainer = table.find('tbody').length > 0 ? table.find('tbody') : table, 29 | rows = rowsContainer.find('tr').has('td, th'), 30 | cells = rows.find(':nth-child(' + (th.index() + 1) + ')').filter('td, th'), 31 | sortBy = th.data().sortBy, 32 | sortedMap = []; 33 | 34 | var unsortedValues = cells.map(function(idx, cell) { 35 | if (sortBy) 36 | return (typeof sortBy === 'function') ? sortBy($(th), $(cell), self) : sortBy; 37 | return ($(this).data().sortValue != null ? $(this).data().sortValue : $(this).text()); 38 | }); 39 | if (unsortedValues.length === 0) return; 40 | 41 | //click on a different column 42 | if (this.index !== th.index()) { 43 | this.direction = 'asc'; 44 | this.index = th.index(); 45 | } 46 | else if (direction !== 'asc' && direction !== 'desc') 47 | this.direction = this.direction === 'asc' ? 'desc' : 'asc'; 48 | else 49 | this.direction = direction; 50 | 51 | direction = this.direction == 'asc' ? 1 : -1; 52 | 53 | self.$table.trigger('tablesort:start', [self]); 54 | self.log("Sorting by " + this.index + ' ' + this.direction); 55 | 56 | // Try to force a browser redraw 57 | self.$table.css("display"); 58 | // Run sorting asynchronously on a timeout to force browser redraw after 59 | // `tablesort:start` callback. Also avoids locking up the browser too much. 60 | setTimeout(function() { 61 | self.$sortCells.removeClass(self.settings.asc + ' ' + self.settings.desc); 62 | for (var i = 0, length = unsortedValues.length; i < length; i++) 63 | { 64 | sortedMap.push({ 65 | index: i, 66 | cell: cells[i], 67 | row: rows[i], 68 | value: unsortedValues[i] 69 | }); 70 | } 71 | 72 | sortedMap.sort(function(a, b) { 73 | return self.settings.compare(a.value, b.value) * direction; 74 | }); 75 | 76 | $.each(sortedMap, function(i, entry) { 77 | rowsContainer.append(entry.row); 78 | }); 79 | 80 | th.addClass(self.settings[self.direction]); 81 | 82 | self.log('Sort finished in ' + ((new Date()).getTime() - start.getTime()) + 'ms'); 83 | self.$table.trigger('tablesort:complete', [self]); 84 | //Try to force a browser redraw 85 | self.$table.css("display"); 86 | }, unsortedValues.length > 2000 ? 200 : 10); 87 | }, 88 | 89 | log: function(msg) { 90 | if(($.tablesort.DEBUG || this.settings.debug) && console && console.log) { 91 | console.log('[tablesort] ' + msg); 92 | } 93 | }, 94 | 95 | destroy: function() { 96 | this.$sortCells.off('click.tablesort'); 97 | this.$table.data('tablesort', null); 98 | return null; 99 | } 100 | 101 | }; 102 | 103 | $.tablesort.DEBUG = false; 104 | 105 | $.tablesort.defaults = { 106 | debug: $.tablesort.DEBUG, 107 | asc: 'sorted ascending', 108 | desc: 'sorted descending', 109 | compare: function(a, b) { 110 | if (a > b) { 111 | return 1; 112 | } else if (a < b) { 113 | return -1; 114 | } else { 115 | return 0; 116 | } 117 | } 118 | }; 119 | 120 | $.fn.tablesort = function(settings) { 121 | var table, sortable, previous; 122 | return this.each(function() { 123 | table = $(this); 124 | previous = table.data('tablesort'); 125 | if(previous) { 126 | previous.destroy(); 127 | } 128 | table.data('tablesort', new $.tablesort(table, settings)); 129 | }); 130 | }; 131 | 132 | })(window.Zepto || window.jQuery); -------------------------------------------------------------------------------- /assets/widget/widget.scss: -------------------------------------------------------------------------------- 1 | body{ 2 | font: normal normal normal 1em/1em arial,helvetica,sans-serif; 3 | } 4 | 5 | /* Button CSS 3 */ 6 | a.btn{ 7 | position: relative; 8 | color: white; 9 | text-transform: lowercase; 10 | font-size: 14px; 11 | font-weight: bold; 12 | display: block; 13 | background: #222; 14 | background-image: -moz-linear-gradient(center top , #262626 0%, #1B1B1B 100%); 15 | background-image: -webkit-gradient(linear, left top,left bottom, color-stop(0,#262626), color-stop(1,#1B1B1B)); 16 | border: 1px solid black; 17 | border-radius: 3px; 18 | padding: 6px 15px 7px 15px; 19 | cursor: pointer; 20 | text-shadow: 0 -2px black; 21 | box-shadow: 0 1px #333333 inset, 0 1px #171717; 22 | } 23 | a.btn:hover{ color: #76bad0; } 24 | a.btn:active{ box-shadow: 0 3px 3px #171717 inset, 0 1px #171717; } 25 | 26 | a.btns{ 27 | display: inline-block; 28 | width: 36px; 29 | height: 31px; 30 | cursor: pointer; 31 | } 32 | 33 | a.btns:hover{ background-position: 0px -32px; } 34 | a.btns.off{ background-position: 0px -64px; } 35 | 36 | a.btn_deezer{ background: url("../img/platforms/platform_deezer.png") no-repeat;} 37 | a.btn_spotify{ background: url("../img/platforms/platform_spotify.png") no-repeat;} 38 | a.btn_qobuz{ background: url("../img/platforms/platform_qobuz.png") no-repeat;} 39 | a.btn_lastfm{ background: url("../img/platforms/platform_lastfm.png") no-repeat;} 40 | a.btn_itunes{ background: url("../img/platforms/platform_itunes.png") no-repeat;} 41 | a.btn_youtube{ background: url("../img/platforms/platform_youtube.png") no-repeat;} 42 | a.btn_soundcloud{ background: url("../img/platforms/platform_soundcloud.png") no-repeat;} 43 | a.btn_mixcloud{ background: url("../img/platforms/platform_mixcloud.png") no-repeat;} 44 | a.btn_groove{ background: url("../img/platforms/platform_groove.png") no-repeat;} 45 | a.btn_napster{ background: url("../img/platforms/platform_napster.png") no-repeat;} 46 | a.btn_tidal{ background: url("../img/platforms/platform_tidal.png") no-repeat;} 47 | a.btn_amazon{ background: url("../img/platforms/platform_amazon.png") no-repeat;} 48 | a.btn_hypem{ background: url("../img/platforms/platform_hypem.png") no-repeat;} 49 | 50 | .txtS{ text-shadow: 0 -1px black !important; } /*standard*/ 51 | 52 | ul li.tHeader{ 53 | display: none; 54 | } 55 | 56 | #waiting{ 57 | background: #1A1A1A; 58 | height: 100%; 59 | left: 0; 60 | opacity: 0.8; 61 | position: absolute; 62 | top: 0; 63 | width: 100%; 64 | z-index: 10; 65 | } 66 | 67 | #waiting img{ 68 | position: absolute; 69 | left: 200px; 70 | top: 66px; 71 | } 72 | 73 | #results{ 74 | padding: 16px; 75 | } 76 | 77 | .nbResults{ 78 | color: #76bad0; 79 | font-size: 18px; 80 | text-shadow: 0 -1px black; 81 | margin-bottom: 10px; 82 | } 83 | 84 | li.tResult{ 85 | box-shadow: 0 1px #070707 inset, 0 1px #333333; 86 | border-radius: 3px; 87 | background: #151515; 88 | height: 60px; 89 | padding: 10px; 90 | } 91 | 92 | li.tResult .coverlay{ 93 | position:absolute; 94 | top:4px; 95 | left:4px; 96 | width: 48px; 97 | height: 48px; 98 | border: 1px solid #AAA; 99 | background: url("../img/coverlay.png") center center; 100 | } 101 | 102 | li.tResult .tImage{ 103 | float: left; 104 | position: relative; 105 | margin-right: 20px; 106 | } 107 | 108 | li.tResult .tImage img{ 109 | width: 50px; 110 | height: 50px; 111 | border: 4px solid white; 112 | box-shadow: 0px 0px 3px black; 113 | display: block; 114 | } 115 | 116 | li.tResult .tTitle{ 117 | font-weight: bold; 118 | font-size: 16px; 119 | color: #A2A2A2; 120 | padding-top: 4px; 121 | } 122 | 123 | li.tResult .tFeat{ 124 | display: none; 125 | } 126 | 127 | li.tResult .tArtist{ 128 | color: #474747; 129 | font-size: 12px; 130 | padding-top: 4px; 131 | } 132 | 133 | li.tResult .tAlbum{ 134 | color: #474747; 135 | font-size: 10px; 136 | padding-top: 2px; 137 | text-transform: uppercase; 138 | } 139 | 140 | li.tResult .tShare{ 141 | position: absolute; 142 | bottom: 14px; 143 | right: 138px; 144 | } 145 | 146 | .tLinks{ 147 | position: absolute; 148 | bottom: 11px; 149 | left: 14px; 150 | } 151 | 152 | .tLinks .wrapper{ 153 | height: 33px; 154 | overflow: hidden; 155 | width: 205px; 156 | } 157 | 158 | #more { 159 | position: absolute; 160 | bottom: 14px; 161 | right: 16px; 162 | } 163 | 164 | #alerts{ 165 | height: 100%; 166 | width: 100%; 167 | text-align: center; 168 | padding-top: 66px; 169 | } 170 | #alerts span{ 171 | text-shadow: 0 -1px black !important; 172 | color: #76bad0; 173 | } 174 | 175 | a.backToTop{ 176 | display: none !important; 177 | } -------------------------------------------------------------------------------- /src/Controller/BackendController.php: -------------------------------------------------------------------------------- 1 | getApiStats(); 26 | $clients = $entityManager->getRepository(ApiClient::class)->findAllWithOAuth2Client(); 27 | $activeClients = array_filter($clients, function ($e) {return $e['active']; }); 28 | $stats = []; 29 | 30 | foreach ($statsRaw as $stat) { 31 | if (isset($stats[StatsService::METHOD_NAMES[$stat['method']]])) { 32 | $stats[StatsService::METHOD_NAMES[$stat['method']]] += $stat['count']; 33 | } else { 34 | $stats[StatsService::METHOD_NAMES[$stat['method']]] = $stat['count']; 35 | } 36 | } 37 | 38 | // Format 39 | foreach ($stats as $key => $stat) { 40 | if ($stat > 1000000) { 41 | $stats[$key] = number_format($stat / 1000000, 2, ',', ' ').' M'; 42 | } elseif ($stat > 1000) { 43 | $stats[$key] = number_format($stat / 1000, 0, ',', ' ').' k'; 44 | } else { 45 | $stats[$key] = number_format($stat, 0, ',', ' '); 46 | } 47 | } 48 | 49 | return $this->render('admin/dashboard.html.twig', [ 50 | 'itemsStats' => $statsService->getItemsStats(), 51 | 'apiStats' => $stats, 52 | 'clients' => $clients, 53 | 'activeClients' => $activeClients, 54 | ]); 55 | } 56 | 57 | #[Route('/api/clients', name: 'clients')] 58 | public function clients(StatsService $statsService, EntityManagerInterface $entityManager): Response 59 | { 60 | $clients = $entityManager->getRepository(ApiClient::class)->findAllWithOAuth2Client(); 61 | $statsRaw = $statsService->getApiStats(); 62 | 63 | $stats = []; 64 | foreach ($statsRaw as $stat) { 65 | $stats[$stat['client_id']][] = [ 66 | 'method' => StatsService::METHOD_NAMES[$stat['method']], 67 | 'count' => $stat['count'], 68 | ]; 69 | } 70 | 71 | return $this->render('admin/clients.html.twig', [ 72 | 'clients' => $clients, 73 | 'stats' => $stats, 74 | ]); 75 | } 76 | 77 | #[Route('/api/clients/new', name: 'new_client')] 78 | public function createClient(Request $request, EntityManagerInterface $entityManager, ClientManagerInterface $clientManager): Response 79 | { 80 | $apiClient = new ApiClient(); 81 | 82 | $form = $this->createFormBuilder($apiClient) 83 | ->add('name', TextType::class) 84 | ->add('email', TextType::class) 85 | ->add('url', TextType::class) 86 | ->add('description', TextType::class) 87 | // Used to create the Oauth2 client 88 | ->add('active', CheckboxType::class, ['mapped' => false]) 89 | ->add('identifier', TextType::class, ['mapped' => false]) 90 | ->add('secret', TextType::class, ['mapped' => false]) 91 | ->getForm(); 92 | 93 | $form->handleRequest($request); 94 | 95 | if ($form->isSubmitted() && $form->isValid()) { 96 | $apiClient = $form->getData(); 97 | $apiClient->setCreatedAt(new \DateTime()); 98 | 99 | // OAuth2 client fields 100 | $active = $form->get('active')->getData(); 101 | $identifier = $form->get('identifier')->getData(); 102 | $secret = $form->get('secret')->getData(); 103 | 104 | $client = new Client($apiClient->getName(), $identifier, $secret); 105 | $client->setActive($active); 106 | 107 | $client 108 | ->setGrants(new Grant('client_credentials'), new Grant('refresh_token')) 109 | ->setScopes(new Scope('api')) 110 | ; 111 | 112 | $clientManager->save($client); 113 | 114 | $apiClient->setOauth2ClientIdentifier($identifier); 115 | 116 | $entityManager->persist($apiClient); 117 | $entityManager->flush(); 118 | 119 | return $this->redirectToRoute('admin_clients'); 120 | } 121 | 122 | return $this->render('admin/clients.new.html.twig', [ 123 | 'form' => $form, 124 | ]); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /templates/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% block social %} 17 | {{ ("pages." ~ page ~ ".title")|trans }} — tuneefy 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {% endblock %} 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {% if embed is defined and embed == true %}{% endif %} 35 | 36 | 37 | 38 | {% if embed is not defined or not embed %} 39 |
    40 | 41 | {% endif %} 42 | 43 |
    44 | 45 |
    46 | 56 |
    57 | 58 |
    59 |
    60 |
    61 |

    tuneefy

    62 |

    {{ "tagline"|trans|raw }}

    63 |
    64 | 65 | {% block content %}{% endblock %} 66 | 67 |
    68 |
    69 |
    70 | 71 | 80 | 81 | {% block javascript %} 82 | 85 | {% if ga_tracker_id %} 86 | 97 | {% endif %} 98 | 99 | 100 | {% endblock %} 101 | 102 | 103 | -------------------------------------------------------------------------------- /src/Dataclass/MusicalEntity/Entities/Album.php: -------------------------------------------------------------------------------- 1 | title = $title; 25 | $this->artist = $artist; 26 | $this->picture = $picture; 27 | 28 | $this->safe_title = $title; 29 | 30 | $this->introspect(); 31 | } 32 | 33 | // Getters and setters 34 | public function getArtist(): string 35 | { 36 | return $this->artist; 37 | } 38 | 39 | public function getTitle(): string 40 | { 41 | return $this->title; 42 | } 43 | 44 | public function getSafeTitle(): string 45 | { 46 | return $this->safe_title; 47 | } 48 | 49 | public function getPicture(): ?string 50 | { 51 | return $this->picture; 52 | } 53 | 54 | public function toArray(): array 55 | { 56 | $result = [ 57 | 'type' => self::TYPE, 58 | 'title' => $this->title, 59 | 'artist' => $this->artist, 60 | 'picture' => $this->picture, 61 | ]; 62 | 63 | if (0 !== $this->countLinkedPlatforms()) { 64 | $result['links'] = $this->links; 65 | } 66 | 67 | if (true === $this->introspected) { 68 | $result['safe_title'] = $this->safe_title; 69 | $result['extra_info'] = $this->extra_info; 70 | } 71 | 72 | return $result; 73 | } 74 | 75 | /* 76 | Strips unnecessary words from an album title 77 | And extracts extra info 78 | */ 79 | public function introspect(): MusicalEntityInterface 80 | { 81 | if (false === $this->introspected) { 82 | // https://secure.php.net/manual/en/function.extract.php 83 | extract(parent::parse($this->title)); 84 | $this->safe_title = $safe_title; 85 | $this->extra_info = $extra_info; 86 | 87 | $this->introspected = true; 88 | } 89 | 90 | return $this; 91 | } 92 | 93 | public function setSafeTitle(string $safe_title): Album 94 | { 95 | $this->safe_title = $safe_title; 96 | 97 | return $this; 98 | } 99 | 100 | public function getHash(bool $aggressive = false): string 101 | { 102 | if (true === $aggressive) { 103 | return Utils::flatten([$this->getExtraInfoHash(), $this->safe_title]); 104 | } else { 105 | return Utils::flatten([$this->getExtraInfoHash(), $this->artist, $this->safe_title]); 106 | } 107 | } 108 | 109 | public static function merge(Album $a, Album $b, bool $force = false): Album 110 | { 111 | // We should only merge tracks that are both covers, acoustic or remixes together 112 | if (!$force && ($a->isCover() !== $b->isCover() 113 | || $a->isRemix() !== $b->isRemix() 114 | || $a->isAcoustic() !== $b->isAcoustic())) { 115 | throw new MusicalEntityMergeException('Impossible to merge album - not forced.'); 116 | } 117 | 118 | // $a has precedence 119 | 120 | if ('' === $a->getTitle()) { 121 | $title = $b->getTitle(); 122 | $safe_title = $b->getSafeTitle(); 123 | } else { 124 | $title = $a->getTitle(); 125 | $safe_title = $a->getSafeTitle(); 126 | } 127 | 128 | if ('' === $a->getArtist()) { 129 | $artist = $b->getArtist(); 130 | } else { 131 | $artist = $a->getArtist(); 132 | } 133 | 134 | if ('' === $a->getPicture()) { 135 | $picture = $b->getPicture(); 136 | } elseif ('' === $b->getPicture()) { 137 | $picture = $a->getPicture(); 138 | } else { 139 | // They both have pictures, prefer NOT deezer 140 | if (false === strpos($a->getPicture(), 'api.deezer.com')) { 141 | $picture = $a->getPicture(); 142 | } else { 143 | $picture = $b->getPicture(); 144 | } 145 | } 146 | 147 | // Create the result 148 | $c = new self($title, $artist, $picture); 149 | 150 | foreach ($b->getLinks() as $platform => $links) { 151 | foreach ($links as $link) { 152 | $a->addLink($platform, $link); 153 | } 154 | } 155 | $c->setLinks($a->getLinks()); 156 | 157 | $c->setExtraInfo([ 158 | 'is_cover' => $a->isCover() || $b->isCover(), 159 | 'is_remix' => $a->isRemix() || $b->isRemix(), 160 | 'acoustic' => $a->isAcoustic() || $b->isAcoustic(), 161 | 'context' => array_unique(array_merge( 162 | $a->getExtraInfo()['context'], 163 | $b->getExtraInfo()['context'] 164 | ), SORT_REGULAR), 165 | ]); 166 | $c->setSafeTitle($safe_title); 167 | 168 | return $c; 169 | } 170 | } 171 | --------------------------------------------------------------------------------