Skip to content

Commit

Permalink
fix media posters failing to load
Browse files Browse the repository at this point in the history
  • Loading branch information
nitwhiz committed Feb 21, 2023
1 parent 5aac541 commit 5e34f21
Show file tree
Hide file tree
Showing 8 changed files with 127 additions and 27 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Helping you find matching movies for you and your SO since 2023!

### Configuration

Example `config.yaml`, suitable for usage with the `docker-compose.yml` from the next section:
Example `config.yml`, suitable for usage with the `docker-compose.yml` from the next section:

```yaml
database:
Expand Down Expand Up @@ -42,7 +42,7 @@ login:
To generate passwords for your user config, run the `hash` command:

```shell
docker run --rm -it ghcr.io/nitwhiz/movie-match-server:latest hash
docker run --rm -it ghcr.io/nitwhiz/movie-match-server:latest hash
```

You should generate passwords with the same version of the server that's going to consume the password.
Expand Down Expand Up @@ -70,7 +70,7 @@ services:
server:
image: ghcr.io/nitwhiz/movie-match-server:latest
volumes:
- "./config.yaml:/opt/movie-match/config.yaml:ro" # mount your config
- "./config.yml:/opt/movie-match/config.yml:ro" # mount your config
- "server_data_posters:/opt/movie-match/posters" # mount a directory to store media posters
ports:
- "6445:6445"
Expand Down
43 changes: 34 additions & 9 deletions app/src/api/ApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ interface Token {
userId: string;
}

const ACCESS_TOKEN_EXPIRY_THRESHOLD = 15 * 60 * 1000;

const ACCESS_TOKEN_COOKIE_NAME = 'jwt';
const ACCESS_TOKEN_COOKIE_EXPIRY_THRESHOLD = 15 * 60 * 1000;

export default class ApiClient extends EventEmitter<{
unauthorized: () => void;
Expand All @@ -32,6 +35,8 @@ export default class ApiClient extends EventEmitter<{

private accessTokenExpiry: number;

private tokenRefreshPromise: Promise<boolean> | null;

constructor(baseUrl: string) {
super();

Expand All @@ -43,6 +48,7 @@ export default class ApiClient extends EventEmitter<{

this.accessToken = '';
this.accessTokenExpiry = 0;
this.tokenRefreshPromise = null;
}

private setAccessToken(token: string) {
Expand All @@ -53,7 +59,9 @@ export default class ApiClient extends EventEmitter<{
this.axios.defaults.headers.common['Content-Type'] = 'application/json';

Cookies.set(ACCESS_TOKEN_COOKIE_NAME, token, {
expires: new Date(this.accessTokenExpiry + 15 * 60 * 1000),
expires: new Date(
this.accessTokenExpiry + ACCESS_TOKEN_COOKIE_EXPIRY_THRESHOLD
),
sameSite: 'Strict',
});
}
Expand All @@ -68,18 +76,28 @@ export default class ApiClient extends EventEmitter<{
return this;
}

private async checkAccessToken(): Promise<void> {
private async checkAccessToken(): Promise<boolean> {
if (this.tokenRefreshPromise) {
return this.tokenRefreshPromise;
}

if (this.accessToken === '' || this.accessTokenExpiry === 0) {
this.emit('unauthorized');
return;
return true;
}

// renew token 5 min before it's invalid
if (Date.now() >= this.accessTokenExpiry - 5 * 60 * 1000) {
if (!(await this.refreshToken())) {
this.emit('unauthorized');
// renew token 15 min before it's invalid
if (Date.now() >= this.accessTokenExpiry - ACCESS_TOKEN_EXPIRY_THRESHOLD) {
if (!this.tokenRefreshPromise) {
this.tokenRefreshPromise = this.refreshToken();
}

return this.tokenRefreshPromise.finally(
() => (this.tokenRefreshPromise = null)
);
}

return true;
}

public login(login: Login): Promise<void> {
Expand Down Expand Up @@ -145,8 +163,15 @@ export default class ApiClient extends EventEmitter<{
return this.axios.get<Media>(`/media/${mediaId}`).then(({ data }) => data);
}

public getPosterUrl(mediaId: string): string {
return `${this.baseUrl}/media/${mediaId}/poster`;
public async getPosterBlobUrl(mediaId: string): Promise<string> {
await this.checkAccessToken();

return this.axios
.get<Blob>(`/media/${mediaId}/poster`, {
responseType: 'blob',
})
.then(({ data }) => data)
.then((blob) => URL.createObjectURL(blob));
}

public async getRecommendedMedia(
Expand Down
42 changes: 42 additions & 0 deletions app/src/api/PosterBlob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useApiClient } from '../composables/useApiClient';

const postersByMediaId: Record<
string,
{ usages: number; urlPromise: Promise<string> }
> = {};

const apiClient = useApiClient().apiClient;

export const getMediaPosterBlobUrl = async (mediaId: string) => {
if (!postersByMediaId[mediaId]) {
postersByMediaId[mediaId] = {
usages: 0,
urlPromise: (await apiClient).getPosterBlobUrl(mediaId),
};
}

++postersByMediaId[mediaId].usages;

return postersByMediaId[mediaId].urlPromise;
};

const free = (mediaId: string) => {
postersByMediaId[mediaId].urlPromise.then((url) => {
if (postersByMediaId[mediaId].usages === 0) {
delete postersByMediaId[mediaId];
URL.revokeObjectURL(url);
}
});
};

export const freeMediaPosterBlobUrl = (mediaId: string) => {
--postersByMediaId[mediaId].usages;

free(mediaId);
};

export const freeAllMediaBlobUrls = () => {
for (const mediaId of Object.keys(postersByMediaId)) {
free(mediaId);
}
};
42 changes: 32 additions & 10 deletions app/src/components/MediaCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
@touchend.passive="handleTouchEnd"
>
<div class="image-holder">
<img :src="posterUrl" alt="" />
<img v-if="currentPosterUrl" :src="currentPosterUrl" alt="" />
</div>
<div class="meta-overlay" v-if="showMeta">
<h3>{{ props.media.title }}</h3>
Expand All @@ -33,9 +33,12 @@
<script lang="ts" setup>
import { PhStar, PhStarHalf } from '@phosphor-icons/vue';
import { Media } from '../model/Media';
import { computed, ref, watch } from 'vue';
import { useApiClient } from '../composables/useApiClient';
import { computed, onMounted, ref, watch } from 'vue';
import { useMediaType } from '../composables/useMediaType';
import {
freeMediaPosterBlobUrl,
getMediaPosterBlobUrl,
} from '../api/PosterBlob';
interface Props {
media: Media;
Expand All @@ -52,11 +55,19 @@ const props = withDefaults(defineProps<Props>(), {
const emits = defineEmits<Emits>();
const apiClient = await useApiClient().apiClient;
const showMeta = ref(props.metaVisible);
const posterUrl = computed(() => apiClient.getPosterUrl(props.media.id));
const currentPosterUrl = ref(null as string | null);
const updatePoster = (previousMedia: Media | null = null) => {
if (previousMedia && props.media.id !== previousMedia.id) {
freeMediaPosterBlobUrl(previousMedia.id);
}
getMediaPosterBlobUrl(props.media.id).then((posterBlobUrl) => {
currentPosterUrl.value = posterBlobUrl;
});
};
const ratingFilled = computed(() => Math.floor(props.media.rating / 20));
const ratingHalf = computed(
Expand Down Expand Up @@ -88,9 +99,13 @@ const inTouch = ref(false);
const isTap = ref(false);
const firstTouch = ref(null as Touch | null);
const cardStyle = computed(() => ({
backgroundImage: `url(${posterUrl.value})`,
}));
const cardStyle = computed(() => {
return currentPosterUrl.value
? {
backgroundImage: `url(${currentPosterUrl.value})`,
}
: {};
});
const { getMediaTypeLabelSingular } = useMediaType();
Expand All @@ -100,7 +115,10 @@ const mediaTypeLabel = computed(() =>
watch(
() => props.media,
() => emits('update:metaVisible', false)
(_, oldMedia) => {
emits('update:metaVisible', false);
updatePoster(oldMedia);
}
);
watch(
Expand Down Expand Up @@ -139,6 +157,10 @@ const handleTouchEnd = () => {
isTap.value = false;
firstTouch.value = null;
};
onMounted(() => {
updatePoster();
});
</script>

<style lang="scss" scoped>
Expand Down
3 changes: 3 additions & 0 deletions app/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import VoteView from './views/VoteView.vue';
import MatchesView from './views/MatchesView.vue';
import MediaView from './views/MediaView.vue';
import LoginView from './views/LoginView.vue';
import { freeAllMediaBlobUrls } from './api/PosterBlob';

export const RouteName = {
LOGIN: 'login',
Expand Down Expand Up @@ -63,6 +64,8 @@ router.beforeEach(async (to, from, next) => {
return;
}

freeAllMediaBlobUrls();

next();
});

Expand Down
14 changes: 11 additions & 3 deletions app/src/views/MatchesView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
</div>
<div class="match" v-for="m in matchList" @click="showMedia(m.media)">
<div class="poster">
<img :src="getMediaPosterSrc(m.media)" :alt="m.media.title" />
<img
v-if="mediaPosters[m.media.id]"
:src="mediaPosters[m.media.id]"
:alt="m.media.title"
/>
</div>
<div class="details">
<b class="name">{{ m.media.title }}</b>
Expand All @@ -31,6 +35,7 @@ import { useRouter } from 'vue-router';
import { useApiClient } from '../composables/useApiClient';
import { useMediaType } from '../composables/useMediaType';
import { useCurrentUser } from '../composables/useCurrentUser';
import { getMediaPosterBlobUrl } from '../api/PosterBlob';
const router = useRouter();
const { currentUser } = useCurrentUser();
Expand All @@ -40,6 +45,8 @@ const { getMediaTypeLabelSingular } = useMediaType();
const filterType = ref('all' as MediaType | 'all');
const mediaPosters = ref({} as Record<string, string>);
const fetchMatches = () => {
apiClient
.getMatches(
Expand All @@ -51,9 +58,12 @@ const fetchMatches = () => {
if (matches) {
for (const match of matches) {
// todo: request pooling?
const media = await apiClient.getMedia(match.mediaId);
matchList.value.push({ match, media });
mediaPosters.value[media.id] = await getMediaPosterBlobUrl(media.id);
}
}
});
Expand All @@ -75,8 +85,6 @@ const showMedia = (media: Media) => {
});
};
const getMediaPosterSrc = (media: Media) => apiClient.getPosterUrl(media.id);
const getGenres = (media: Media) => media.genres.map((g) => g.name).join(', ');
onMounted(() => {
Expand Down
1 change: 1 addition & 0 deletions server/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.idea/
config*.yml
config*.yaml
3 changes: 1 addition & 2 deletions server/internal/auth/auth_jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,7 @@ func GetJWTMiddleware(db *gorm.DB) (*jwt.GinJWTMiddleware, error) {
Timeout: time.Hour,
MaxRefresh: time.Hour,
IdentityKey: jwtIdentityKey,
// we still need cookies for movie/tv-show posters
TokenLookup: "header: Authorization, cookie: jwt",
TokenLookup: "header: Authorization",
Authenticator: func(c *gin.Context) (interface{}, error) {
var loginParams login

Expand Down

0 comments on commit 5e34f21

Please sign in to comment.